@django-stack/cli 0.5.0 → 0.7.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 { A as generateSecret, B as isDockerDown, C as applies, D as resolvePreset, E as pendingChoices, F as portValues, H as quoteEnvValue, I as runDoctor, L as checkTools, M as chooseFreePorts, N as describeMove, O as stackOption, P as envPorts, R as dockerNotRunning, S as STACK_OPTIONS, T as encodePreset, U as setEnvValues, V as parseEnv, _ as validateTimeZone, a as LOCAL_S3, b as NAMED_PRESETS, c as lockBackend, d as scaffold, f as timeZones, g as validateProjectName, h as usesValkey, j as DEV_PORTS, k as generateProjectSecrets, m as umamiEnv, n as productionEnv$1, o as cacheEnv, p as toProjectName, r as validateDomain, s as localS3, t as missingS3, u as relativeDirectory, v as CLI_VERSION, w as decodePreset, x as PROMPT_ORDER, y as DEFAULT_STACK, z as dockerRunning } from "./production-DBpWWx6i.js";
3
3
  import { createRequire } from "node:module";
4
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
- import { basename, delimiter, dirname, join, resolve } from "node:path";
4
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
5
+ import { basename, delimiter, dirname, join, relative, 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).filter(([, definition]) => definition.deploy?.replicas !== 0).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",
@@ -701,7 +751,7 @@ cat ${shellQuote(`${id}.log`)} 2>/dev/null
701
751
  const record = JSON.parse(json);
702
752
  const [status = "", alive = "0", updatedAt] = state.split(" ");
703
753
  return {
704
- ...toDeployment(record, recordStatus(status, alive === "1"), updatedAt || void 0),
754
+ ...toDeployment$2(record, recordStatus(status, alive === "1"), updatedAt || void 0),
705
755
  log: log ? log.replace(/\n$/, "").split("\n").map((text) => ({
706
756
  text,
707
757
  stderr: false
@@ -710,7 +760,7 @@ cat ${shellQuote(`${id}.log`)} 2>/dev/null
710
760
  }
711
761
  async deployments(link, limit) {
712
762
  return (await this.records(link, limit)).map((item) => ({
713
- ...toDeployment(item.record, item.status, item.updatedAt),
763
+ ...toDeployment$2(item.record, item.status, item.updatedAt),
714
764
  log: []
715
765
  }));
716
766
  }
@@ -729,7 +779,7 @@ sed -n 's/^SITE_URL=//p' ${shellQuote(`${link.project}/shared/.env`)} 2>/dev/nul
729
779
  };
730
780
  }
731
781
  };
732
- function toDeployment(record, status, updatedAt) {
782
+ function toDeployment$2(record, status, updatedAt) {
733
783
  return {
734
784
  id: record.id,
735
785
  status,
@@ -768,12 +818,1279 @@ const ssh = {
768
818
  placeholder: "deploy@example.com",
769
819
  normalize: (value) => formatTarget(parseTarget(value))
770
820
  },
771
- platformEnv: {},
772
- setupFlags: [{
773
- name: "path",
774
- description: "The project directory on the server (default: /srv/<project>)"
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
+ if (isDockerDown(`${result.stderr}\n${result.stdout}`)) throw new Error(dockerNotRunning());
1130
+ const last = (result.stderr.trim() || result.stdout.trim()).split("\n").at(-1) ?? "";
1131
+ throw new Error(`docker ${args[0]} failed (exit ${result.code})${last ? `: ${last}` : "."}`);
1132
+ }
1133
+ async git(directory, args) {
1134
+ return this.run("git", [
1135
+ "-C",
1136
+ directory,
1137
+ ...args
1138
+ ]);
1139
+ }
1140
+ /** The short commit and its subject. */
1141
+ async commit(directory) {
1142
+ const result = await this.git(directory, [
1143
+ "log",
1144
+ "-1",
1145
+ "--format=%h%n%s"
1146
+ ]);
1147
+ if (result.code !== 0) throw new Error("This project has no commit yet. Commit it first: git add -A && git commit");
1148
+ const [commit = "", message = ""] = result.stdout.trim().split("\n");
1149
+ return {
1150
+ commit,
1151
+ message
1152
+ };
1153
+ }
1154
+ /** Extracts the local commit into a temporary directory, so the images match the commit. */
1155
+ async checkout(directory) {
1156
+ const target = mkdtempSync(join(tmpdir(), "dstack-bunny-"));
1157
+ const archive = await this.git(directory, [
1158
+ "archive",
1159
+ "--format=tar",
1160
+ "-o",
1161
+ join(target, ".archive.tar"),
1162
+ "HEAD"
1163
+ ]);
1164
+ if (archive.code !== 0) {
1165
+ rmSync(target, {
1166
+ recursive: true,
1167
+ force: true
1168
+ });
1169
+ throw new Error(`git archive failed: ${archive.stderr.trim().split("\n").at(-1) ?? ""}`);
1170
+ }
1171
+ const extract = await this.run("tar", [
1172
+ "-x",
1173
+ "-f",
1174
+ ".archive.tar"
1175
+ ], { cwd: target });
1176
+ rmSync(join(target, ".archive.tar"), { force: true });
1177
+ if (extract.code !== 0) {
1178
+ rmSync(target, {
1179
+ recursive: true,
1180
+ force: true
1181
+ });
1182
+ throw new Error(`Could not extract the commit: ${extract.stderr.trim()}`);
1183
+ }
1184
+ return target;
1185
+ }
1186
+ /** The production Compose project, resolved with the variables, as JSON. */
1187
+ async composeConfig(directory, env) {
1188
+ const folder = mkdtempSync(join(tmpdir(), "dstack-env-"));
1189
+ const file = join(folder, ".env");
1190
+ writeFileSync(file, `${Object.entries(env).map(([key, value]) => `${key}=${envValue(value)}`).join("\n")}\n`, { mode: 384 });
1191
+ try {
1192
+ const output = await this.docker([
1193
+ "compose",
1194
+ "--env-file",
1195
+ file,
1196
+ "config",
1197
+ "--format",
1198
+ "json"
1199
+ ], {
1200
+ cwd: directory,
1201
+ env: {
1202
+ ...env,
1203
+ ENV_FILE: file
1204
+ }
1205
+ });
1206
+ return JSON.parse(output);
1207
+ } finally {
1208
+ rmSync(folder, {
1209
+ recursive: true,
1210
+ force: true
1211
+ });
1212
+ }
1213
+ }
1214
+ async plan(directory, env) {
1215
+ return planApp(await this.composeConfig(directory, env), { readFile: this.readFile });
1216
+ }
1217
+ /** Builds an image for linux/amd64 and pushes it to Bunny Container Registry. */
1218
+ async buildAndPush(build, reference, directory) {
1219
+ if (build.kind === "build") await this.docker([
1220
+ "build",
1221
+ "--platform",
1222
+ "linux/amd64",
1223
+ ...build.target ? ["--target", build.target] : [],
1224
+ ...build.dockerfile ? ["-f", resolve(build.context, build.dockerfile)] : [],
1225
+ ...Object.entries(build.args ?? {}).flatMap(([key, value]) => ["--build-arg", `${key}=${value}`]),
1226
+ "-t",
1227
+ reference,
1228
+ build.context
1229
+ ], {
1230
+ cwd: directory,
1231
+ stream: true
1232
+ });
1233
+ else {
1234
+ const context = mkdtempSync(join(tmpdir(), "dstack-bake-"));
1235
+ try {
1236
+ const lines = [`FROM ${build.from}`];
1237
+ build.files.forEach((file, index) => {
1238
+ writeFileSync(join(context, `file-${index}`), file.content);
1239
+ lines.push(`COPY file-${index} ${file.target}`);
1240
+ });
1241
+ writeFileSync(join(context, "Dockerfile"), `${lines.join("\n")}\n`);
1242
+ await this.docker([
1243
+ "build",
1244
+ "--platform",
1245
+ "linux/amd64",
1246
+ "-t",
1247
+ reference,
1248
+ context
1249
+ ], { stream: true });
1250
+ } finally {
1251
+ rmSync(context, {
1252
+ recursive: true,
1253
+ force: true
1254
+ });
1255
+ }
1256
+ }
1257
+ const config = mkdtempSync(join(tmpdir(), "dstack-docker-"));
1258
+ try {
1259
+ await this.docker([
1260
+ "login",
1261
+ BUNNY_REGISTRY,
1262
+ "--username",
1263
+ "token",
1264
+ "--password-stdin"
1265
+ ], {
1266
+ env: { DOCKER_CONFIG: config },
1267
+ input: this.token
1268
+ });
1269
+ await this.docker(["push", reference], {
1270
+ env: { DOCKER_CONFIG: config },
1271
+ stream: true
1272
+ });
1273
+ } finally {
1274
+ rmSync(config, {
1275
+ recursive: true,
1276
+ force: true
1277
+ });
1278
+ }
1279
+ }
1280
+ /** Builds and pushes every image of the local commit; returns the plan of the commit. */
1281
+ async pushImages(directory, env, namespace, tag, log) {
1282
+ const checkout = await this.checkout(directory);
1283
+ try {
1284
+ const plan = await this.plan(checkout, env);
1285
+ for (const build of plan.builds) {
1286
+ const reference = `${BUNNY_REGISTRY}/${namespace}/${build.repository}:${tag}`;
1287
+ log(`Building and pushing ${reference}`);
1288
+ await this.buildAndPush(build, reference, checkout);
1289
+ }
1290
+ return plan;
1291
+ } finally {
1292
+ rmSync(checkout, {
1293
+ recursive: true,
1294
+ force: true
1295
+ });
1296
+ }
1297
+ }
1298
+ async registries() {
1299
+ const list = listOf(await this.api.mc("GET", "/registries"));
1300
+ const registries = {};
1301
+ let namespace;
1302
+ for (const item of list) {
1303
+ const type = (text(item.type) ?? "").toLowerCase();
1304
+ const id = text(item.id);
1305
+ if (!id) continue;
1306
+ if (type === "dockerhub" && !registries.dockerHub) registries.dockerHub = id;
1307
+ else if (type === "github" && !registries.gitHub) registries.gitHub = id;
1308
+ else if ((type === "bunnyregistry" || type === "bunny") && !registries.bunny) {
1309
+ registries.bunny = id;
1310
+ namespace = text(item.namespace ?? item.accountId ?? item.userName ?? item.username);
1311
+ }
1312
+ }
1313
+ return {
1314
+ registries,
1315
+ namespace
1316
+ };
1317
+ }
1318
+ async ensureRegistries() {
1319
+ const found = await this.registries();
1320
+ if (!found.registries.dockerHub) throw new Error("Bunny lists no public Docker Hub registry for this account. Check Magic Containers › Image Registries.");
1321
+ if (!found.registries.bunny) {
1322
+ const created = await this.api.mc("POST", "/registries", {
1323
+ displayName: "Bunny Container Registry",
1324
+ type: "bunnyRegistry"
1325
+ });
1326
+ const id = text(created?.id) ?? text(findKey(created, /^id$/i));
1327
+ if (!id) throw new Error("Could not add Bunny Container Registry to Magic Containers.");
1328
+ found.registries.bunny = id;
1329
+ }
1330
+ return {
1331
+ registries: found.registries,
1332
+ namespace: found.namespace
1333
+ };
1334
+ }
1335
+ async app(link) {
1336
+ return await this.api.mc("GET", `/apps/${encodeURIComponent(link.project)}`) ?? {};
1337
+ }
1338
+ async defaultEdgeServers() {
1339
+ const addresses = [];
1340
+ for (const path of ["/system/edgeserverlist", "/system/edgeserverlist/ipv6"]) {
1341
+ const response = await this.fetchImpl(`${BUNNY_API}${path}`, { headers: { Accept: "application/json" } });
1342
+ if (!response.ok) throw new Error(`Bunny answered ${response.status} to ${path}`);
1343
+ const list = await response.json();
1344
+ if (Array.isArray(list)) {
1345
+ for (const item of list) if (typeof item === "string") addresses.push(item);
1346
+ }
1347
+ }
1348
+ return addresses;
1349
+ }
1350
+ /** TRUSTED_PROXIES for Caddy: the addresses Bunny's CDN connects from. */
1351
+ async trustedProxies() {
1352
+ try {
1353
+ const addresses = await this.edgeServersImpl();
1354
+ return addresses.length ? addresses.join(" ") : void 0;
1355
+ } catch {
1356
+ return;
1357
+ }
1358
+ }
1359
+ async pullZoneId(appId) {
1360
+ for (let attempt = 0; attempt < 10; attempt++) {
1361
+ const id = text(findKey(await this.api.mc("GET", `/apps/${encodeURIComponent(appId)}/endpoints`), /^pull_?zone_?id$/i));
1362
+ if (id && id !== "0") return id;
1363
+ await new Promise((done) => setTimeout(done, attempt ? 3e3 : 1e3));
1364
+ }
1365
+ }
1366
+ async pullZone(id) {
1367
+ return await this.api.request("GET", `/pullzone/${encodeURIComponent(id)}`) ?? {};
1368
+ }
1369
+ /** The custom hostname, its certificate and a cache bypass for pages that must not be cached. */
1370
+ async setupPullZone(id, domain, step) {
1371
+ const zone = `/pullzone/${encodeURIComponent(id)}`;
1372
+ await this.api.request("POST", zone, { AddHostHeader: true });
1373
+ if (!listOf((await this.pullZone(id)).Hostnames).map((item) => text(item.Value)?.toLowerCase()).includes(domain.toLowerCase())) {
1374
+ await this.api.request("POST", `${zone}/addHostname`, { Hostname: domain });
1375
+ step(`Added ${domain} to the CDN pull zone`);
1376
+ }
1377
+ await this.api.request("POST", `${zone}/edgerules/addOrUpdate`, {
1378
+ ActionType: 3,
1379
+ ActionParameter1: "0",
1380
+ ActionParameter2: "0",
1381
+ Triggers: [{
1382
+ Type: 0,
1383
+ PatternMatches: [
1384
+ "*/admin",
1385
+ "*/admin/*",
1386
+ "*/api",
1387
+ "*/api/*",
1388
+ "*/_a/*"
1389
+ ],
1390
+ PatternMatchingType: 0
1391
+ }],
1392
+ TriggerMatchingType: 0,
1393
+ Description: "dstack: do not cache the admin, the API and analytics",
1394
+ Enabled: true
1395
+ });
1396
+ await this.certificate(id, domain);
1397
+ }
1398
+ /** Requests the free certificate once DNS points at the CDN; returns whether it is active. */
1399
+ async certificate(id, domain) {
1400
+ if (listOf((await this.pullZone(id)).Hostnames).find((item) => text(item.Value)?.toLowerCase() === domain.toLowerCase())?.HasCertificate === true) return true;
1401
+ try {
1402
+ await this.api.request("GET", `/pullzone/loadFreeCertificate?hostname=${encodeURIComponent(domain)}`);
1403
+ await this.api.request("POST", `/pullzone/${encodeURIComponent(id)}/setForceSSL`, {
1404
+ Hostname: domain,
1405
+ ForceSSL: true
1406
+ });
1407
+ return true;
1408
+ } catch {
1409
+ return false;
1410
+ }
1411
+ }
1412
+ async cname(id) {
1413
+ const name = text((await this.pullZone(id)).Name);
1414
+ return name ? `${name}.b-cdn.net` : void 0;
1415
+ }
1416
+ recordsDir(directory) {
1417
+ const folder = join(directory, BUNNY_DEPLOYS_DIR);
1418
+ if (!existsSync(folder)) {
1419
+ mkdirSync(folder, { recursive: true });
1420
+ writeFileSync(join(folder, ".gitignore"), "*\n");
1421
+ }
1422
+ return folder;
1423
+ }
1424
+ writeRecord(directory, record, log) {
1425
+ const folder = this.recordsDir(directory);
1426
+ writeFileSync(join(folder, `${record.id}.json`), `${JSON.stringify(record, null, 2)}\n`);
1427
+ writeFileSync(join(folder, `${record.id}.log`), log.length ? `${log.join("\n")}\n` : "");
1428
+ }
1429
+ records(directory) {
1430
+ const folder = join(directory, BUNNY_DEPLOYS_DIR);
1431
+ if (!existsSync(folder)) return [];
1432
+ const records = [];
1433
+ for (const file of readdirSync(folder)) {
1434
+ if (!file.endsWith(".json")) continue;
1435
+ try {
1436
+ records.push(JSON.parse(readFileSync(join(folder, file), "utf8")));
1437
+ } catch {}
1438
+ }
1439
+ return records.sort((a, b) => b.id.localeCompare(a.id));
1440
+ }
1441
+ async setup(context) {
1442
+ const { ask, options } = context;
1443
+ const { directory } = context;
1444
+ const volumeSize = Number.parseInt(options.volumeSize ?? "10", 10);
1445
+ if (!Number.isFinite(volumeSize) || volumeSize < 1 || volumeSize > 100) throw new Error("Give --volume-size in GB, from 1 to 100.");
1446
+ const env = { ...context.env };
1447
+ const trusted = await this.trustedProxies();
1448
+ if (trusted) env.TRUSTED_PROXIES = trusted;
1449
+ 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.");
1450
+ const local = await this.plan(directory, env);
1451
+ if (local.ignored.length) ask.step(`Bunny runs the app in a gVisor sandbox; not applied from compose.yaml: ${local.ignored.join(", ")}`);
1452
+ const { registries, namespace: found } = await this.ensureRegistries();
1453
+ let namespace = options.account ?? found;
1454
+ if (!namespace) namespace = (await ask.text("Bunny account id (Magic Containers › Image Registries, or bunny whoami)", { placeholder: "123456" })).trim();
1455
+ if (!/^[\w.-]+$/.test(namespace)) throw new Error(`${namespace} is not a Bunny account id.`);
1456
+ let region = options.region;
1457
+ if (!region) {
1458
+ const optimal = text(findKey(await this.api.mc("GET", "/regions/optimal").catch(() => void 0), /^id$|regionid/i));
1459
+ const regions = listOf(await this.api.mc("GET", "/regions")).map((item) => ({
1460
+ value: text(item.id) ?? "",
1461
+ label: text(item.name) ?? text(item.id) ?? ""
1462
+ })).filter((item) => item.value);
1463
+ if (regions.length) region = await ask.select("Region", regions, optimal ?? regions[0]?.value);
1464
+ else region = optimal;
1465
+ }
1466
+ if (!region) throw new Error("Pass --region, such as DE.");
1467
+ const { commit, message } = await this.commit(directory);
1468
+ const uncommitted = uncommittedReason(directory);
1469
+ if (uncommitted) ask.step(`Building the last commit, ${commit}: ${uncommitted.replace(/ Commit first\.$/, "")}`);
1470
+ const log = [];
1471
+ const plan = await this.pushImages(directory, env, namespace, commit, (line) => {
1472
+ log.push(line);
1473
+ ask.step(line);
1474
+ });
1475
+ const appId = text((await this.api.mc("POST", "/apps", appRequest(plan, {
1476
+ registries,
1477
+ namespace,
1478
+ tag: commit,
1479
+ region,
1480
+ volumeSize
1481
+ })))?.id);
1482
+ if (!appId) throw new Error("Bunny created the app but returned no id.");
1483
+ ask.step(`Created the app ${plan.name} in ${region}`);
1484
+ try {
1485
+ await this.api.mc("POST", `/apps/${encodeURIComponent(appId)}/deploy`);
1486
+ } catch (error) {
1487
+ if (!(error instanceof BunnyError && [400, 409].includes(error.status))) throw error;
1488
+ }
1489
+ const zone = await this.pullZoneId(appId);
1490
+ if (!zone) throw new Error(`Bunny has not created the CDN endpoint of app ${appId} yet. Run dstack deploy setup --force later.`);
1491
+ await this.setupPullZone(zone, context.domain, ask.step);
1492
+ const target = await this.cname(zone);
1493
+ 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)" : ""}`);
1494
+ this.writeRecord(directory, {
1495
+ id: deployId(this.now(), commit),
1496
+ tag: commit,
1497
+ commit,
1498
+ message,
1499
+ createdAt: this.now().toISOString()
1500
+ }, log);
1501
+ return {
1502
+ project: appId,
1503
+ environment: namespace,
1504
+ application: zone
1505
+ };
1506
+ }
1507
+ async pushEnv(link, env) {
1508
+ const directory = projectDirectory(link);
1509
+ const values = { ...env };
1510
+ const trusted = await this.trustedProxies();
1511
+ if (trusted) values.TRUSTED_PROXIES = trusted;
1512
+ const plan = await this.plan(directory, values);
1513
+ const containers = appContainers(await this.app(link));
1514
+ for (const container of plan.containers) {
1515
+ const remote = containers.find((item) => item.name === container.name);
1516
+ if (!remote) throw new Error(missingContainer(container.name));
1517
+ await this.api.mc("PUT", `/apps/${encodeURIComponent(link.project)}/containers/${encodeURIComponent(remote.id)}/env`, { environmentVariables: envList(container.env) });
1518
+ }
1519
+ }
1520
+ async deploy(link, options) {
1521
+ const { directory } = options;
1522
+ const { commit, message } = await this.commit(directory);
1523
+ const id = deployId(this.now(), commit);
1524
+ const log = [];
1525
+ const record = {
1526
+ id,
1527
+ tag: commit,
1528
+ commit,
1529
+ message,
1530
+ createdAt: this.now().toISOString()
1531
+ };
1532
+ const containers = appContainers(await this.app(link));
1533
+ const env = {
1534
+ ...productionEnv(directory),
1535
+ ...BUNNY_PLATFORM_ENV
1536
+ };
1537
+ try {
1538
+ const local = await this.plan(directory, env);
1539
+ const pushed = local.containers.filter((container) => container.image.source === "push");
1540
+ for (const container of local.containers) if (!containers.some((item) => item.name === container.name)) throw new Error(missingContainer(container.name));
1541
+ if (pushed.every((container) => containers.find((item) => item.name === container.name)?.imageTag === commit)) {
1542
+ if (options.force) {
1543
+ await this.api.mc("POST", `/apps/${encodeURIComponent(link.project)}/restart`);
1544
+ log.push(`Restarted the app on ${commit}`);
1545
+ } else log.push(`The app already runs ${commit}; nothing to deploy. Pass --force to restart it.`);
1546
+ } else {
1547
+ await this.pushImages(directory, env, link.environment, commit, (line) => {
1548
+ log.push(line);
1549
+ process.stderr.write(`${line}\n`);
1550
+ });
1551
+ await this.retag(link, containers, pushed.map((container) => container.name), commit);
1552
+ log.push(`Bunny is rolling out ${commit}`);
1553
+ }
1554
+ if (link.application) {
1555
+ const domain = (env.SITE_URL ?? "").replace(/^https?:\/\//, "").replace(/\/.*$/, "");
1556
+ if (domain) await this.certificate(link.application, domain).catch(() => false);
1557
+ }
1558
+ } catch (error) {
1559
+ log.push(error.message);
1560
+ this.writeRecord(directory, {
1561
+ ...record,
1562
+ failed: true
1563
+ }, log);
1564
+ throw error;
1565
+ }
1566
+ this.writeRecord(directory, record, log);
1567
+ this.prune(directory);
1568
+ return id;
1569
+ }
1570
+ /** Points the containers of pushed images at a tag; Bunny starts a rolling update. */
1571
+ async retag(link, remote, names, tag) {
1572
+ for (const name of names) {
1573
+ const item = remote.find((entry) => entry.name === name);
1574
+ if (!item || item.imageTag === tag) continue;
1575
+ await this.api.mc("PATCH", `/apps/${encodeURIComponent(link.project)}/containers/${encodeURIComponent(item.id)}`, { imageTag: tag });
1576
+ }
1577
+ }
1578
+ /** Removes records beyond KEEP_DEPLOYS. Their images stay in the registry. */
1579
+ prune(directory) {
1580
+ const folder = join(directory, BUNNY_DEPLOYS_DIR);
1581
+ for (const record of this.records(directory).slice(20)) {
1582
+ rmSync(join(folder, `${record.id}.json`), { force: true });
1583
+ rmSync(join(folder, `${record.id}.log`), { force: true });
1584
+ }
1585
+ }
1586
+ async rollback(link, to) {
1587
+ const directory = projectDirectory(link);
1588
+ const records = this.records(directory);
1589
+ const containers = appContainers(await this.app(link));
1590
+ const pushed = containers.filter((item) => isPushed(item, link));
1591
+ const currentTag = pushed[0]?.imageTag;
1592
+ let target;
1593
+ if (to) {
1594
+ target = records.find((item) => item.id === to || item.tag === to);
1595
+ if (!target) throw new Error(`There is no deploy ${to} in ${BUNNY_DEPLOYS_DIR}.`);
1596
+ } else {
1597
+ target = records.find((item) => !item.failed && item.tag !== currentTag);
1598
+ if (!target) throw new Error("There is no earlier deploy to roll back to.");
1599
+ }
1600
+ if (target.failed) throw new Error(`Deploy ${target.id} failed, so its images may not exist.`);
1601
+ const id = deployId(this.now(), "rollback");
1602
+ await this.retag(link, containers, pushed.map((item) => item.name), target.tag);
1603
+ this.writeRecord(directory, {
1604
+ id,
1605
+ tag: target.tag,
1606
+ commit: target.commit,
1607
+ message: `Rollback to ${target.id}`,
1608
+ createdAt: this.now().toISOString(),
1609
+ rollbackOf: target.id
1610
+ }, [`Bunny is rolling out ${target.tag}`]);
1611
+ return id;
1612
+ }
1613
+ /** The tag the app's own images run, and Bunny's state. */
1614
+ async current(link) {
1615
+ const app = await this.app(link);
1616
+ return {
1617
+ tag: appContainers(app).filter((item) => isPushed(item, link))[0]?.imageTag,
1618
+ status: appStatusText(app)
1619
+ };
1620
+ }
1621
+ statusOf(record, latest, current) {
1622
+ if (record.failed) return "failed";
1623
+ if (record.tag === current.tag && latest) {
1624
+ if (bunnyBusy(current.status)) return "building";
1625
+ const state = bunnyState(current.status);
1626
+ if (state === "running" || state === "unknown") return "finished";
1627
+ return "failed";
1628
+ }
1629
+ if (latest) return this.now().getTime() - new Date(record.createdAt).getTime() < 12e4 ? "queued" : "failed";
1630
+ return "finished";
1631
+ }
1632
+ async deployment(link, id) {
1633
+ const directory = projectDirectory(link);
1634
+ const records = this.records(directory);
1635
+ const record = records.find((item) => item.id === id);
1636
+ if (!record) throw new Error(`There is no deploy ${id} in ${BUNNY_DEPLOYS_DIR}.`);
1637
+ const current = await this.current(link);
1638
+ const logFile = join(directory, BUNNY_DEPLOYS_DIR, `${id}.log`);
1639
+ const log = existsSync(logFile) ? readFileSync(logFile, "utf8").replace(/\n$/, "") : "";
1640
+ return {
1641
+ ...toDeployment$1(record, this.statusOf(record, records[0]?.id === id, current), link),
1642
+ log: log ? log.split("\n").map((line) => ({
1643
+ text: line,
1644
+ stderr: false
1645
+ })) : []
1646
+ };
1647
+ }
1648
+ async deployments(link, limit) {
1649
+ const records = this.records(projectDirectory(link)).slice(0, Math.max(1, limit));
1650
+ if (!records.length) return [];
1651
+ const current = await this.current(link);
1652
+ return records.map((record, index) => ({
1653
+ ...toDeployment$1(record, this.statusOf(record, index === 0, current), link),
1654
+ log: []
1655
+ }));
1656
+ }
1657
+ async status(link) {
1658
+ const word = appStatusText(await this.app(link));
1659
+ const state = bunnyBusy(word) ? "degraded" : bunnyState(word);
1660
+ const details = [word ?? "no status"];
1661
+ const domains = [];
1662
+ if (link.application) try {
1663
+ const zone = await this.pullZone(link.application);
1664
+ for (const item of listOf(zone.Hostnames)) {
1665
+ const host = text(item.Value);
1666
+ if (!host || item.IsSystemHostname === true || host.endsWith(".b-cdn.net")) continue;
1667
+ domains.push(`https://${host}`);
1668
+ if (item.HasCertificate !== true) details.push(`no certificate for ${host} yet: check its DNS record`);
1669
+ }
1670
+ } catch {
1671
+ details.push("could not read the CDN pull zone");
1672
+ }
1673
+ return {
1674
+ state,
1675
+ detail: details.join(", "),
1676
+ domains,
1677
+ url: dashboardUrl(link)
1678
+ };
1679
+ }
1680
+ };
1681
+ function missingContainer(name) {
1682
+ return `The app on Bunny has no container ${name}. compose.yaml changed since setup: run dstack deploy setup --force to create the app again.`;
1683
+ }
1684
+ /** Whether a Bunny container runs an image this project pushes: one in the account's namespace. */
1685
+ function isPushed(container, link) {
1686
+ return container.imageNamespace === link.environment;
1687
+ }
1688
+ /** The project directory, where the deploy records are. */
1689
+ function projectDirectory(link) {
1690
+ return link.directory ?? process.cwd();
1691
+ }
1692
+ function dashboardUrl(link) {
1693
+ return `https://dash.bunny.net/magic-containers/apps/${encodeURIComponent(link.project)}`;
1694
+ }
1695
+ function toDeployment$1(record, status, link) {
1696
+ return {
1697
+ id: record.id,
1698
+ status,
1699
+ commit: record.commit,
1700
+ message: record.message,
1701
+ createdAt: record.createdAt,
1702
+ url: dashboardUrl(link)
1703
+ };
1704
+ }
1705
+ /** The variables of .env.production, or of .env on a machine where that is the production one. */
1706
+ function productionEnv(directory) {
1707
+ for (const name of [".env.production", ".env"]) {
1708
+ const file = join(directory, name);
1709
+ if (existsSync(file)) return parseEnv(readFileSync(file, "utf8"));
1710
+ }
1711
+ throw new Error(".env.production does not exist. Run dstack production --domain example.com first.");
1712
+ }
1713
+ const bunny = {
1714
+ id: "bunny",
1715
+ name: "Bunny Magic Containers",
1716
+ auth: {
1717
+ kind: "token",
1718
+ env: "DSTACK_BUNNY_TOKEN",
1719
+ 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."
1720
+ },
1721
+ source: "local",
1722
+ target: {
1723
+ label: "Bunny API",
1724
+ placeholder: BUNNY_API,
1725
+ fixed: BUNNY_API,
1726
+ normalize: () => BUNNY_API
1727
+ },
1728
+ platformEnv: BUNNY_PLATFORM_ENV,
1729
+ setupFlags: [
1730
+ {
1731
+ name: "region",
1732
+ description: "Bunny: the region of the app, such as DE (default: the closest)"
1733
+ },
1734
+ {
1735
+ name: "volumeSize",
1736
+ description: "Bunny: the size of the database volume in GB (default: 10)"
1737
+ },
1738
+ {
1739
+ name: "account",
1740
+ description: "Bunny: the account id, the namespace in Bunny Container Registry"
1741
+ }
1742
+ ],
1743
+ printsDns: true,
1744
+ connect: (_target, token) => {
1745
+ if (!token) throw new Error("Bunny needs an API key.");
1746
+ return new BunnyClient(token);
1747
+ }
1748
+ };
1749
+ // src/deploy/provider.ts
1750
+ /** The target of a platform with a web API and an API key. */
1751
+ function urlTarget(name, placeholder) {
1752
+ return {
1753
+ label: `${name} URL`,
1754
+ placeholder,
1755
+ normalize: normalizeUrl
1756
+ };
1757
+ }
1758
+ /** Normalizes an instance URL: https by default, no trailing slash or path. */
1759
+ function normalizeUrl(value) {
1760
+ const trimmed = value.trim();
1761
+ const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
1762
+ let parsed;
1763
+ try {
1764
+ parsed = new URL(withScheme);
1765
+ } catch {
1766
+ throw new Error(`${value} is not a URL. Enter one such as https://coolify.example.com.`);
1767
+ }
1768
+ return `${parsed.protocol}//${parsed.host}`;
1769
+ }
1770
+ // src/deploy/coolify.ts
1771
+ /**
1772
+ * Coolify (https://coolify.io), through its API at /api/v1 with a bearer
1773
+ * token. The project deploys as a Docker Compose application that Coolify
1774
+ * builds on the server from the git repository; Coolify's proxy gets the
1775
+ * certificate and forwards to the `proxy` service (Caddy) on port 80.
1776
+ */
1777
+ /** Variables that put Caddy behind Coolify's proxy. See the docs page on platforms. */
1778
+ const PLATFORM_ENV = {
1779
+ CADDY_ADDRESS: ":80",
1780
+ TRUSTED_PROXIES: "private_ranges",
1781
+ NUM_PROXIES: "2",
1782
+ HTTP_PORT: "127.0.0.1:8080",
1783
+ HTTPS_PORT: "127.0.0.1:8443"
1784
+ };
1785
+ /** Setup flags Coolify and Dokploy share; `deploy setup` shows each flag once. */
1786
+ const SHARED_SETUP_FLAGS = [
1787
+ {
1788
+ name: "server",
1789
+ description: "The server on the platform, by name or id (Dokploy: dokploy for its own)"
1790
+ },
1791
+ {
1792
+ name: "project",
1793
+ description: "The platform's project, by name or id (default: the project name)"
1794
+ },
1795
+ {
1796
+ name: "environment",
1797
+ description: "The platform's environment (default: production)"
1798
+ }
1799
+ ];
1800
+ var CoolifyError = class extends Error {
1801
+ status;
1802
+ constructor(message, status) {
1803
+ super(message);
1804
+ this.status = status;
1805
+ }
1806
+ };
1807
+ var CoolifyApi = class {
1808
+ url;
1809
+ token;
1810
+ fetchImpl;
1811
+ constructor(url, token, fetchImpl = fetch) {
1812
+ this.url = url;
1813
+ this.token = token;
1814
+ this.fetchImpl = fetchImpl;
1815
+ }
1816
+ async request(method, path, body) {
1817
+ let response;
1818
+ try {
1819
+ response = await this.fetchImpl(`${this.url}/api/v1${path}`, {
1820
+ method,
1821
+ headers: {
1822
+ Authorization: `Bearer ${this.token}`,
1823
+ Accept: "application/json",
1824
+ ...body === void 0 ? {} : { "Content-Type": "application/json" }
1825
+ },
1826
+ body: body === void 0 ? void 0 : JSON.stringify(body),
1827
+ signal: AbortSignal.timeout(3e4)
1828
+ });
1829
+ } catch (error) {
1830
+ throw new CoolifyError(`Could not reach Coolify at ${this.url}: ${error.message}`, 0);
1831
+ }
1832
+ const text = await response.text();
1833
+ if (!response.ok) throw new CoolifyError(errorMessage$1(response.status, text, this.url), response.status);
1834
+ if (!text) return void 0;
1835
+ try {
1836
+ return JSON.parse(text);
1837
+ } catch {
1838
+ return text;
1839
+ }
1840
+ }
1841
+ get = (path) => this.request("GET", path);
1842
+ post = (path, body) => this.request("POST", path, body ?? {});
1843
+ patch = (path, body) => this.request("PATCH", path, body);
1844
+ };
1845
+ function errorMessage$1(status, text, url) {
1846
+ if (status === 401) return `Coolify at ${url} rejected the API key. Run dstack deploy login to enter a new one.`;
1847
+ if (status === 403) return "The API key may not do this. Give it the read, write and deploy permissions in Coolify under Keys & Tokens.";
1848
+ if (status === 404 && text.includes("<html")) return `${url} does not answer like Coolify; check the URL.`;
1849
+ let message = "";
1850
+ try {
1851
+ const body = JSON.parse(text);
1852
+ message = body.message ?? "";
1853
+ if (body.errors) message += ` ${Object.values(body.errors).flat().join(" ")}`;
1854
+ } catch {
1855
+ message = text.slice(0, 200);
1856
+ }
1857
+ return `Coolify answered ${status}${message ? `: ${message.trim()}` : "."}`;
1858
+ }
1859
+ const enc = encodeURIComponent;
1860
+ function deploymentStatus(value) {
1861
+ if (value === "queued") return "queued";
1862
+ if (value === "in_progress") return "building";
1863
+ if (value === "finished") return "finished";
1864
+ if (value.startsWith("cancelled")) return "cancelled";
1865
+ return "failed";
1866
+ }
1867
+ function appState(value) {
1868
+ const [state = "", health = ""] = (value ?? "").split(/[:(]/);
1869
+ if (state === "running") return health.startsWith("unhealthy") ? "degraded" : "running";
1870
+ if (state === "degraded" || state === "restarting") return "degraded";
1871
+ if (state === "exited" || state === "stopped") return "stopped";
1872
+ return "unknown";
1873
+ }
1874
+ /** Coolify keeps the log as JSON text: a list of { output, type, hidden }. */
1875
+ function parseLog(logs) {
1876
+ if (!logs) return [];
1877
+ let entries;
1878
+ try {
1879
+ entries = JSON.parse(logs);
1880
+ } catch {
1881
+ return logs.split("\n").map((text) => ({
1882
+ text,
1883
+ stderr: false
1884
+ }));
1885
+ }
1886
+ if (!Array.isArray(entries)) return [];
1887
+ return entries.filter((entry) => !entry.hidden && entry.output).flatMap((entry) => String(entry.output).split("\n").map((text) => ({
1888
+ text,
1889
+ stderr: entry.type === "stderr"
1890
+ })));
1891
+ }
1892
+ /** The domains of a Compose application, by service. */
1893
+ function composeDomains(app) {
1894
+ let value = app.docker_compose_domains;
1895
+ if (typeof value === "string") try {
1896
+ value = JSON.parse(value);
1897
+ } catch {
1898
+ value = null;
1899
+ }
1900
+ const domains = Object.values(value ?? {}).flatMap((service) => (service?.domain ?? "").split(",")).map((domain) => domain.trim()).filter(Boolean);
1901
+ if (!domains.length && app.fqdn) domains.push(...app.fqdn.split(",").map((d) => d.trim()));
1902
+ return domains.map((domain) => domain.replace(/:\d+$/, ""));
1903
+ }
1904
+ function appUrl(api, link) {
1905
+ return `${api.url}/project/${link.project}/environment/${link.environment}/application/${link.application}`;
1906
+ }
1907
+ function toDeployment(api, link, item) {
1908
+ const path = item.deployment_url;
1909
+ return {
1910
+ id: item.deployment_uuid,
1911
+ status: deploymentStatus(item.status),
1912
+ commit: item.commit && item.commit !== "HEAD" ? item.commit.slice(0, 7) : void 0,
1913
+ message: item.commit_message?.split("\n")[0] || void 0,
1914
+ createdAt: item.created_at,
1915
+ updatedAt: item.updated_at,
1916
+ url: path ? `${api.url}${path.startsWith("/") ? "" : "/"}${path}` : `${appUrl(api, link)}/deployment/${item.deployment_uuid}`,
1917
+ log: parseLog(item.logs)
1918
+ };
1919
+ }
1920
+ /** Finds a name or uuid in a list. */
1921
+ function find$1(items, wanted) {
1922
+ return items.find((item) => item.uuid === wanted) ?? items.find((item) => item.name === wanted);
1923
+ }
1924
+ var CoolifyClient = class {
1925
+ api;
1926
+ constructor(url, token, fetchImpl) {
1927
+ this.api = new CoolifyApi(url, token, fetchImpl);
1928
+ }
1929
+ async verify() {
1930
+ const version = await this.api.get("/version");
1931
+ await this.api.get("/servers");
1932
+ return String(version).trim();
1933
+ }
1934
+ async setup(context) {
1935
+ const { ask, options } = context;
1936
+ if (!context.repository) throw new Error("Coolify builds from a git repository; pass --repository.");
1937
+ const api = this.api;
1938
+ const servers = await api.get("/servers");
1939
+ if (!servers.length) throw new Error("This Coolify has no servers. Add one in Coolify first.");
1940
+ let server;
1941
+ if (options.server) {
1942
+ server = find$1(servers, options.server);
1943
+ if (!server) throw new Error(`Coolify has no server ${options.server}.`);
1944
+ } else if (servers.length === 1) server = servers[0];
1945
+ else server = find$1(servers, await ask.select("Server to deploy to", servers.map((item) => ({
1946
+ value: item.uuid,
1947
+ label: item.name,
1948
+ hint: item.ip
1949
+ }))));
1950
+ if (!server) throw new Error("No server chosen.");
1951
+ const projects = await api.get("/projects");
1952
+ let project;
1953
+ const projectName = options.project ?? context.name;
1954
+ project = find$1(projects, projectName);
1955
+ if (!options.project && ask.interactive && projects.length) {
1956
+ const NEW = "";
1957
+ const uuid = await ask.select("Coolify project", [...project ? [] : [{
1958
+ value: NEW,
1959
+ label: `Create ${projectName}`
1960
+ }], ...projects.map((item) => ({
1961
+ value: item.uuid,
1962
+ label: item.name,
1963
+ hint: item.description ?? void 0
1964
+ }))], project?.uuid ?? NEW);
1965
+ project = uuid === NEW ? void 0 : find$1(projects, uuid);
1966
+ }
1967
+ if (!project) {
1968
+ if (options.project && /^[a-z0-9]{20,}$/.test(options.project)) throw new Error(`Coolify has no project ${options.project}.`);
1969
+ project = {
1970
+ uuid: (await api.post("/projects", {
1971
+ name: projectName,
1972
+ description: "DjangoStack"
1973
+ })).uuid,
1974
+ name: projectName
1975
+ };
1976
+ ask.step(`Created the Coolify project ${projectName}`);
1977
+ }
1978
+ const environmentName = options.environment ?? "production";
1979
+ let environment = find$1(await api.get(`/projects/${enc(project.uuid)}/environments`), environmentName);
1980
+ if (!environment) {
1981
+ environment = {
1982
+ uuid: (await api.post(`/projects/${enc(project.uuid)}/environments`, { name: environmentName })).uuid,
1983
+ name: environmentName
1984
+ };
1985
+ ask.step(`Created the environment ${environmentName}`);
1986
+ }
1987
+ let githubApp;
1988
+ if (options.githubApp && options.githubApp !== "public") {
1989
+ const app = find$1(await api.get("/github-apps"), options.githubApp);
1990
+ if (!app) throw new Error(`Coolify has no GitHub App ${options.githubApp}.`);
1991
+ githubApp = app.uuid;
1992
+ } else if (!options.githubApp && ask.interactive) {
1993
+ const apps = (await api.get("/github-apps").catch(() => [])).filter((app) => app.uuid && app.name);
1994
+ if (apps.length) {
1995
+ const choice = await ask.select("How Coolify gets the code", [{
1996
+ value: "public",
1997
+ label: "Public repository",
1998
+ hint: "no access needed"
1999
+ }, ...apps.map((app) => ({
2000
+ value: app.uuid,
2001
+ label: `GitHub App ${app.name}`,
2002
+ hint: "private repository"
2003
+ }))], "public");
2004
+ githubApp = choice === "public" ? void 0 : choice;
2005
+ }
2006
+ }
2007
+ const body = {
2008
+ project_uuid: project.uuid,
2009
+ server_uuid: server.uuid,
2010
+ environment_uuid: environment.uuid,
2011
+ git_repository: context.repository,
2012
+ git_branch: context.branch,
2013
+ build_pack: "dockercompose",
2014
+ docker_compose_location: "/compose.yaml",
2015
+ docker_compose_domains: [{
2016
+ name: "proxy",
2017
+ domain: `https://${context.domain}:80`
2018
+ }],
2019
+ name: context.name,
2020
+ description: "DjangoStack",
2021
+ instant_deploy: false,
2022
+ ...githubApp ? { github_app_uuid: githubApp } : {}
2023
+ };
2024
+ const created = await api.post(githubApp ? "/applications/private-github-app" : "/applications/public", body);
2025
+ ask.step(`Created the application ${context.name} from ${context.repository} (${context.branch})`);
2026
+ const link = {
2027
+ provider: "coolify",
2028
+ url: api.url,
2029
+ project: project.uuid,
2030
+ environment: environment.uuid,
2031
+ application: created.uuid
2032
+ };
2033
+ await this.pushEnv(link, context.env);
2034
+ ask.step(`Set ${Object.keys(context.env).length} environment variables`);
2035
+ return {
2036
+ project: link.project,
2037
+ environment: link.environment,
2038
+ application: link.application
2039
+ };
2040
+ }
2041
+ async pushEnv(link, env) {
2042
+ const data = Object.entries(env).map(([key, value]) => ({
2043
+ key,
2044
+ value,
2045
+ is_literal: true,
2046
+ is_preview: false
2047
+ }));
2048
+ await this.api.patch(`/applications/${enc(link.application)}/envs/bulk`, { data });
2049
+ }
2050
+ async deploy(link, options) {
2051
+ const query = `uuid=${enc(link.application)}${options.force ? "&force=true" : ""}`;
2052
+ const result = await this.api.post(`/deploy?${query}`);
2053
+ const id = result?.deployments?.[0]?.deployment_uuid;
2054
+ if (!id) throw new Error(result?.deployments?.[0]?.message ?? "Coolify did not start a deployment.");
2055
+ return id;
2056
+ }
2057
+ async deployment(link, id) {
2058
+ return toDeployment(this.api, link, await this.api.get(`/deployments/${enc(id)}`));
2059
+ }
2060
+ async deployments(link, limit) {
2061
+ const result = await this.api.get(`/deployments/applications/${enc(link.application)}?take=${limit}`);
2062
+ return (Array.isArray(result) ? result : result?.deployments ?? []).map((item) => toDeployment(this.api, link, item));
2063
+ }
2064
+ async status(link) {
2065
+ const app = await this.api.get(`/applications/${enc(link.application)}`);
2066
+ return {
2067
+ state: appState(app.status),
2068
+ detail: app.status ?? "unknown",
2069
+ domains: composeDomains(app),
2070
+ branch: app.git_branch,
2071
+ url: appUrl(this.api, link)
2072
+ };
2073
+ }
2074
+ };
2075
+ const coolify = {
2076
+ id: "coolify",
2077
+ name: "Coolify",
2078
+ auth: {
2079
+ kind: "token",
2080
+ env: "DSTACK_COOLIFY_TOKEN",
2081
+ 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.`
2082
+ },
2083
+ source: "remote",
2084
+ target: urlTarget("Coolify", "https://coolify.example.com"),
2085
+ platformEnv: PLATFORM_ENV,
2086
+ setupFlags: [...SHARED_SETUP_FLAGS, {
2087
+ name: "githubApp",
2088
+ description: "A Coolify GitHub App for a private repository, or public"
775
2089
  }],
776
- connect: (target) => new SshClient(target)
2090
+ connect: (url, token) => {
2091
+ if (!token) throw new Error("Coolify needs an API key.");
2092
+ return new CoolifyClient(url, token);
2093
+ }
777
2094
  };
778
2095
  // src/deploy/dokploy.ts
779
2096
  var DokployError = class extends Error {
@@ -1164,123 +2481,14 @@ function deleteToken(provider, url) {
1164
2481
  } catch {}
1165
2482
  return removeFromFile(account) || removed;
1166
2483
  }
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
2484
  // src/deploy/index.ts
1278
2485
  const DEFAULT_PROVIDER = coolify;
1279
2486
  /** Every deploy provider. */
1280
2487
  const PROVIDERS = [
1281
2488
  coolify,
1282
2489
  dokploy,
1283
- ssh
2490
+ ssh,
2491
+ bunny
1284
2492
  ];
1285
2493
  function getProvider(id) {
1286
2494
  const provider = PROVIDERS.find((item) => item.id === id);
@@ -1362,17 +2570,45 @@ function projectManifest(project) {
1362
2570
  /**
1363
2571
  * The project's stack: from the preset `init` saved in `package.json`, or,
1364
2572
  * for projects from before that, the defaults with the storage from `.env`.
2573
+ * Analytics follow the `analytics` profile: projects from before Umami was
2574
+ * generated have the default in their preset without running it.
1365
2575
  */
1366
2576
  function projectStack(project) {
2577
+ const fromEnv = {
2578
+ analytics: project.profiles.includes("analytics") ? "umami" : "off",
2579
+ ...project.env.CACHE_URL === void 0 ? {} : cacheStack(project.env)
2580
+ };
1367
2581
  const { preset } = projectManifest(project);
1368
2582
  if (preset) try {
1369
- return decodePreset(preset);
2583
+ return {
2584
+ ...decodePreset(preset),
2585
+ ...fromEnv
2586
+ };
1370
2587
  } catch {}
1371
2588
  return {
1372
2589
  ...DEFAULT_STACK,
2590
+ ...fromEnv,
1373
2591
  storage: project.profiles.includes("s3") ? "s3" : "local"
1374
2592
  };
1375
2593
  }
2594
+ const REDIS = /^(redis|rediss|valkey|valkeys):/;
2595
+ /**
2596
+ * The cache and the tasks a project's `.env` runs. Projects from before 0.7
2597
+ * have no TASKS_URL: their tasks follow a Redis cache, like the backend does.
2598
+ */
2599
+ function cacheStack(env) {
2600
+ const cacheUrl = env.CACHE_URL ?? "";
2601
+ const cache = cacheUrl.startsWith("db:") ? "database" : REDIS.test(cacheUrl) ? "valkey" : "off";
2602
+ const tasksUrl = env.TASKS_URL || (cache === "valkey" ? cacheUrl : "immediate://");
2603
+ if (tasksUrl.startsWith("immediate:")) return {
2604
+ cache,
2605
+ tasks: "off"
2606
+ };
2607
+ return {
2608
+ cache,
2609
+ tasks: REDIS.test(tasksUrl) && cache !== "valkey" ? "huey-valkey" : "huey"
2610
+ };
2611
+ }
1376
2612
  /** The URLs of a running project. */
1377
2613
  function projectUrls(project) {
1378
2614
  if (project.prod) {
@@ -1385,23 +2621,25 @@ function projectUrls(project) {
1385
2621
  url: `${site}/admin/`
1386
2622
  }];
1387
2623
  }
2624
+ const ports = envPorts(project.env);
2625
+ const site = (project.env.SITE_URL || `http://localhost:${ports.SITE_PORT}`).replace(/\/$/, "");
1388
2626
  const urls = [
1389
2627
  {
1390
2628
  label: "Site",
1391
- url: "http://localhost:3000"
2629
+ url: site
1392
2630
  },
1393
2631
  {
1394
2632
  label: "Admin",
1395
- url: "http://localhost:3000/admin/"
2633
+ url: `${site}/admin/`
1396
2634
  },
1397
2635
  {
1398
2636
  label: "Mail",
1399
- url: "http://localhost:8025"
2637
+ url: `http://localhost:${ports.MAIL_PORT}`
1400
2638
  }
1401
2639
  ];
1402
2640
  if (project.profiles.includes("s3")) urls.push({
1403
2641
  label: "S3",
1404
- url: "http://localhost:8333"
2642
+ url: `http://localhost:${ports.S3_PORT}`
1405
2643
  });
1406
2644
  return urls;
1407
2645
  }
@@ -1650,7 +2888,7 @@ async function deploySetup(options) {
1650
2888
  }))
1651
2889
  }));
1652
2890
  const provider = providerOrFail(providerId);
1653
- let instance = options.url;
2891
+ let instance = options.url ?? provider.target.fixed;
1654
2892
  if (!instance) {
1655
2893
  if (!interactive) fail(`Pass --url, the address of your ${provider.name}.`);
1656
2894
  instance = unwrap(await p.text({
@@ -1693,6 +2931,7 @@ async function deploySetup(options) {
1693
2931
  for (const flag of provider.setupFlags) flags[flag.name] = options[flag.name];
1694
2932
  const ids = await attempt(client.setup({
1695
2933
  ask: promptAsk(interactive),
2934
+ directory: project.directory,
1696
2935
  name: project.name,
1697
2936
  repository,
1698
2937
  branch,
@@ -1703,11 +2942,12 @@ async function deploySetup(options) {
1703
2942
  const link = {
1704
2943
  provider: provider.id,
1705
2944
  url: instance,
1706
- ...ids
2945
+ ...ids,
2946
+ directory: project.directory
1707
2947
  };
1708
2948
  writeLink(project.directory, link);
1709
2949
  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");
2950
+ 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
2951
  if (options.deploy ?? (interactive ? unwrap(await p.confirm({
1712
2952
  message: "Deploy now?",
1713
2953
  initialValue: true
@@ -1901,7 +3141,7 @@ async function loginTarget(options, interactive) {
1901
3141
  } catch {}
1902
3142
  const provider = providerOrFail(options.provider ?? link?.provider ?? DEFAULT_PROVIDER.id);
1903
3143
  tokenAuth(provider);
1904
- let instance = options.url ?? link?.url;
3144
+ let instance = options.url ?? link?.url ?? provider.target.fixed;
1905
3145
  if (!instance) {
1906
3146
  if (!interactive) fail(`Pass --url, the address of your ${provider.name}.`);
1907
3147
  instance = unwrap(await p.text({
@@ -2206,14 +3446,23 @@ async function offerGlobalInstall() {
2206
3446
  return true;
2207
3447
  }
2208
3448
  // src/commands/init.ts
2209
- /** The prompt choices of an option; values this version does not generate yet are shown but disabled. */
2210
- function choices(key) {
2211
- return stackOption(key).values.map((value, index) => ({
2212
- value: value.value,
2213
- label: value.label,
2214
- hint: value.generated || index === 0 ? value.hint : "coming soon",
2215
- disabled: !value.generated && index > 0
2216
- }));
3449
+ /**
3450
+ * The prompt choices of an option; values this version does not generate yet
3451
+ * are shown but disabled. The tasks follow the cache chosen before them: Huey
3452
+ * keeps its queue in the database, or in Valkey with the Valkey cache.
3453
+ */
3454
+ function choices(key, stack = {}) {
3455
+ return stackOption(key).values.flatMap((value, index) => {
3456
+ let hint = value.generated || index === 0 ? value.hint : "coming soon";
3457
+ if (key === "tasks" && value.value === "huey") hint = stack.cache === "valkey" ? "a worker container, the queue in Valkey" : "a worker container, the queue in Postgres";
3458
+ if (key === "tasks" && value.value === "huey-valkey" && stack.cache === "valkey") return [];
3459
+ return [{
3460
+ value: value.value,
3461
+ label: value.label,
3462
+ hint,
3463
+ disabled: !value.generated && index > 0
3464
+ }];
3465
+ });
2217
3466
  }
2218
3467
  /**
2219
3468
  * The stack from, in order of precedence, the flags, the preset, the prompts
@@ -2229,8 +3478,8 @@ async function chooseStack(options, interactive, askDatabase) {
2229
3478
  }
2230
3479
  const stack = { ...DEFAULT_STACK };
2231
3480
  let databaseUrl;
2232
- for (const option of STACK_OPTIONS) {
2233
- const key = option.key;
3481
+ for (const key of PROMPT_ORDER) {
3482
+ const option = stackOption(key);
2234
3483
  if (options.fromCli(key)) stack[key] = options[key];
2235
3484
  else if (key === "storage" && options.fromCli("localS3") && options.localS3) stack[key] = "s3";
2236
3485
  else if (preset) stack[key] = preset[key];
@@ -2240,11 +3489,14 @@ async function chooseStack(options, interactive, askDatabase) {
2240
3489
  if ("databaseUrl" in answer) databaseUrl = answer.databaseUrl;
2241
3490
  else stack[key] = answer.postgres;
2242
3491
  } else if (key === "postgres" && options.databaseUrl) continue;
2243
- else stack[key] = unwrap(await p.select({
2244
- message: option.message,
2245
- options: choices(key),
2246
- initialValue: stack[key]
2247
- }));
3492
+ else {
3493
+ const initialValue = key === "storage" && stack.media === "s3" ? "s3" : stack[key];
3494
+ stack[key] = unwrap(await p.select({
3495
+ message: option.message,
3496
+ options: choices(key, stack),
3497
+ initialValue
3498
+ }));
3499
+ }
2248
3500
  }
2249
3501
  return {
2250
3502
  stack,
@@ -2339,6 +3591,8 @@ async function init(directoryArgument, options) {
2339
3591
  message: "Add demo content? (example pages and posts)",
2340
3592
  initialValue: true
2341
3593
  }));
3594
+ const ports = await chooseFreePorts({}, stack.storage === "s3" ? ["s3"] : []);
3595
+ for (const move of ports.moves) p.log.info(describeMove(move));
2342
3596
  const spinner = p.spinner();
2343
3597
  spinner.start("Creating the project");
2344
3598
  let result;
@@ -2350,8 +3604,12 @@ async function init(directoryArgument, options) {
2350
3604
  demoContent,
2351
3605
  databaseUrl,
2352
3606
  localS3: stack.storage === "s3",
3607
+ analytics: stack.analytics === "umami",
3608
+ cache: stack.cache,
3609
+ tasks: stack.tasks,
2353
3610
  preset: encodePreset(stack),
2354
3611
  timeZone,
3612
+ ports: ports.ports,
2355
3613
  link: options.link
2356
3614
  });
2357
3615
  } catch (error) {
@@ -2386,22 +3644,24 @@ async function init(directoryArgument, options) {
2386
3644
  } catch {
2387
3645
  p.log.warn("Could not commit the project with git. Commit it yourself.");
2388
3646
  }
3647
+ if (options.installPrompt !== false && !options.yes) await offerGlobalInstall();
3648
+ const startCommand = isOnPath() ? "dstack dev" : "docker compose up";
2389
3649
  p.note([
2390
3650
  command(`cd ${relativeDirectory(target)}`),
2391
- command("docker compose up"),
3651
+ command(startCommand),
2392
3652
  "",
2393
- `${pc.bold("Site")} ${url("http://localhost:3000")}`,
2394
- `${pc.bold("Admin")} ${url("http://localhost:3000/admin/")}`,
2395
- `${pc.bold("Mail")} ${url("http://localhost:8025")}`,
3653
+ `${pc.bold("Site")} ${url(`http://localhost:${ports.ports.SITE_PORT}`)}`,
3654
+ `${pc.bold("Admin")} ${url(`http://localhost:${ports.ports.SITE_PORT}/admin/`)}`,
3655
+ `${pc.bold("Mail")} ${url(`http://localhost:${ports.ports.MAIL_PORT}`)}`,
2396
3656
  "",
2397
3657
  `Admin user ${secret(result.adminUsername)}, password ${secret(result.adminPassword)}`,
2398
3658
  pc.dim("The password is in .env as ADMIN_PASSWORD. Change it after you sign in.")
2399
3659
  ].join("\n"), "Next steps");
2400
3660
  if (pending.length) p.log.info(`Coming in a later version, so not in this project yet: ${pending.map((item) => `${stackOption(item.key).message.toLowerCase()} (${stack[item.key]})`).join(", ")}.`);
3661
+ if (stack.media === "s3") p.log.info(`Uploads go to S3 in production. Before the first deploy, ${command("dstack production")} asks for the S3 settings; ${command("dstack settings")} changes them later.`);
2401
3662
  p.log.message(`Create the same stack with ${command(`--preset ${encodePreset(stack)}`)}`);
2402
- if (options.start && !dockerReady) p.outro(pc.yellow(`Docker is not ready, so the project was not started. Fix the warnings above and run ${command("docker compose up")}.`));
3663
+ if (options.start && !dockerReady) p.outro(pc.yellow(`Docker is not ready, so the project was not started. Fix the warnings above and run ${command(startCommand)}.`));
2403
3664
  else if (options.start) {
2404
- if (options.installPrompt !== false && !options.yes) await offerGlobalInstall();
2405
3665
  p.outro(`Starting the project with ${command("docker compose up")}`);
2406
3666
  execFileSync("docker", ["compose", "up"], {
2407
3667
  cwd: target,
@@ -2420,8 +3680,9 @@ async function preset(code, options) {
2420
3680
  } catch (error) {
2421
3681
  fail(error.message);
2422
3682
  }
2423
- const lines = STACK_OPTIONS.filter((option) => applies(option.key, stack)).map((option) => {
2424
- const value = option.values.find((candidate) => candidate.value === stack[option.key]);
3683
+ const lines = PROMPT_ORDER.filter((key) => applies(key, stack)).map((key) => {
3684
+ const option = stackOption(key);
3685
+ const value = option.values.find((candidate) => candidate.value === stack[key]);
2425
3686
  return `${pc.bold(option.message.padEnd(24))}${value?.label ?? ""}`;
2426
3687
  });
2427
3688
  p.note(lines.join("\n"), command(`--preset ${encodePreset(stack)}`));
@@ -2432,6 +3693,295 @@ async function preset(code, options) {
2432
3693
  const encoded = encodePreset(stack);
2433
3694
  p.outro(`Create this stack with ${command(`${selfCommand()} init --preset ${encoded}`)}`);
2434
3695
  }
3696
+ // src/settings.ts
3697
+ function validateEmail(value) {
3698
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? void 0 : "Enter an email address, such as you@example.com.";
3699
+ }
3700
+ function validateHost(value) {
3701
+ return /^[a-z0-9.-]+$/i.test(value) && value.includes(".") ? void 0 : "Enter a host name, such as smtp.example.com.";
3702
+ }
3703
+ function validatePort(value) {
3704
+ const port = Number(value);
3705
+ return Number.isInteger(port) && port > 0 && port < 65536 ? void 0 : "Enter a port, such as 587.";
3706
+ }
3707
+ function validateUrl(value) {
3708
+ if (!value) return void 0;
3709
+ return /^https?:\/\/[^\s/]+/.test(value) ? void 0 : "Enter a URL that starts with https://.";
3710
+ }
3711
+ const required = (label) => (value) => value.trim() ? void 0 : `Enter the ${label}.`;
3712
+ /**
3713
+ * An SMTP URL for EMAIL_URL: smtps:// on port 465, smtp:// on 25, otherwise
3714
+ * smtp+tls:// (STARTTLS), with the user name and password URL-encoded.
3715
+ */
3716
+ function smtpUrl(parts) {
3717
+ const port = Number(parts.port || "587");
3718
+ const scheme = port === 465 ? "smtps" : port === 25 ? "smtp" : "smtp+tls";
3719
+ const user = parts.username ? encodeURIComponent(parts.username) : "";
3720
+ const password = parts.password ? `:${encodeURIComponent(parts.password)}` : "";
3721
+ return `${scheme}://${user ? `${user}${password}@` : ""}${parts.host}:${port}`;
3722
+ }
3723
+ /** The parts of an SMTP URL, for the current answers. */
3724
+ function smtpParts(value) {
3725
+ try {
3726
+ const url = new URL(value ?? "");
3727
+ return {
3728
+ host: url.hostname,
3729
+ port: url.port,
3730
+ username: decodeURIComponent(url.username),
3731
+ password: decodeURIComponent(url.password)
3732
+ };
3733
+ } catch {
3734
+ return {};
3735
+ }
3736
+ }
3737
+ const placeholderMail = (value) => !value || value.includes("mailpit") || value.includes("example.com") || value.startsWith("console:");
3738
+ const SETTING_GROUPS = [
3739
+ {
3740
+ title: "Admin email",
3741
+ names: ["ADMIN_EMAIL"],
3742
+ applies: () => true,
3743
+ missing: (env) => !env.ADMIN_EMAIL || env.ADMIN_EMAIL.endsWith("@example.com") ? ["ADMIN_EMAIL"] : [],
3744
+ fields: [{
3745
+ name: "ADMIN_EMAIL",
3746
+ message: "Your email address, for the admin user",
3747
+ placeholder: "you@example.com",
3748
+ validate: validateEmail
3749
+ }],
3750
+ values: (answers) => ({ ADMIN_EMAIL: answers.ADMIN_EMAIL ?? "" })
3751
+ },
3752
+ {
3753
+ title: "Outgoing mail",
3754
+ names: ["EMAIL_URL"],
3755
+ applies: () => true,
3756
+ missing: (env) => placeholderMail(env.EMAIL_URL) ? ["EMAIL_URL"] : [],
3757
+ fields: [
3758
+ {
3759
+ name: "host",
3760
+ message: "SMTP server",
3761
+ placeholder: "smtp.example.com",
3762
+ validate: validateHost
3763
+ },
3764
+ {
3765
+ name: "port",
3766
+ message: "SMTP port",
3767
+ defaultValue: "587",
3768
+ validate: validatePort
3769
+ },
3770
+ {
3771
+ name: "username",
3772
+ message: "SMTP user name",
3773
+ optional: true
3774
+ },
3775
+ {
3776
+ name: "password",
3777
+ message: "SMTP password",
3778
+ secret: true,
3779
+ optional: true
3780
+ }
3781
+ ],
3782
+ current: (env, field) => placeholderMail(env.EMAIL_URL) ? void 0 : smtpParts(env.EMAIL_URL)[field],
3783
+ values: (answers) => ({ EMAIL_URL: smtpUrl({
3784
+ host: answers.host ?? "",
3785
+ port: answers.port ?? "587",
3786
+ username: answers.username,
3787
+ password: answers.password
3788
+ }) })
3789
+ },
3790
+ {
3791
+ title: "S3 for uploads",
3792
+ names: [
3793
+ "S3_BUCKET",
3794
+ "S3_ENDPOINT_URL",
3795
+ "S3_REGION",
3796
+ "S3_ACCESS_KEY_ID",
3797
+ "S3_SECRET_ACCESS_KEY",
3798
+ "S3_PUBLIC_URL"
3799
+ ],
3800
+ applies: (env, context) => context.media === "s3" || Boolean(env.S3_BUCKET),
3801
+ missing: missingS3,
3802
+ fields: [
3803
+ {
3804
+ name: "S3_BUCKET",
3805
+ message: "S3 bucket",
3806
+ placeholder: "example-media",
3807
+ validate: required("bucket")
3808
+ },
3809
+ {
3810
+ name: "S3_ENDPOINT_URL",
3811
+ message: "S3 endpoint, empty for AWS",
3812
+ placeholder: "https://fsn1.your-objectstorage.com",
3813
+ optional: true,
3814
+ validate: validateUrl
3815
+ },
3816
+ {
3817
+ name: "S3_REGION",
3818
+ message: "S3 region",
3819
+ placeholder: "eu-central-1",
3820
+ validate: required("region")
3821
+ },
3822
+ {
3823
+ name: "S3_ACCESS_KEY_ID",
3824
+ message: "S3 access key ID",
3825
+ validate: required("access key ID")
3826
+ },
3827
+ {
3828
+ name: "S3_SECRET_ACCESS_KEY",
3829
+ message: "S3 secret access key",
3830
+ secret: true,
3831
+ validate: required("secret access key")
3832
+ },
3833
+ {
3834
+ name: "S3_PUBLIC_URL",
3835
+ message: "Public URL of the files, empty when it is the endpoint",
3836
+ placeholder: "https://media.example.com",
3837
+ optional: true,
3838
+ validate: validateUrl
3839
+ }
3840
+ ],
3841
+ current: (env, field) => env[field],
3842
+ values: (answers) => Object.fromEntries([
3843
+ "S3_BUCKET",
3844
+ "S3_ENDPOINT_URL",
3845
+ "S3_REGION",
3846
+ "S3_ACCESS_KEY_ID",
3847
+ "S3_SECRET_ACCESS_KEY",
3848
+ "S3_PUBLIC_URL"
3849
+ ].map((name) => [name, answers[name] ?? ""]))
3850
+ }
3851
+ ];
3852
+ /** The groups of a production `.env` to ask for: the missing ones, or with `all` every one that applies. */
3853
+ function settingsToAsk(env, context, all = false) {
3854
+ return SETTING_GROUPS.filter((group) => group.applies(env, context) && (all || group.missing(env).length));
3855
+ }
3856
+ /** Every `.env` variable `--set` takes. */
3857
+ const SETTING_NAMES = SETTING_GROUPS.flatMap((group) => group.names);
3858
+ /** Parses `--set NAME=value` arguments. Throws with a one-line reason. */
3859
+ function parseAssignments(items) {
3860
+ const values = {};
3861
+ for (const item of items) {
3862
+ const index = item.indexOf("=");
3863
+ const name = index > 0 ? item.slice(0, index) : "";
3864
+ if (!SETTING_NAMES.includes(name)) throw new Error(`${JSON.stringify(item)} is not NAME=value for one of ${SETTING_NAMES.join(", ")}.`);
3865
+ const value = item.slice(index + 1);
3866
+ if (/[\r\n]/.test(value)) throw new Error(`The value of ${name} has a line break.`);
3867
+ if (name === "ADMIN_EMAIL" && validateEmail(value)) throw new Error(`ADMIN_EMAIL: ${validateEmail(value)}`);
3868
+ values[name] = value;
3869
+ }
3870
+ return values;
3871
+ }
3872
+ /** The items of `production`'s to-do list that the settings now in `env` have done. */
3873
+ function remainingTodo(todo, env) {
3874
+ return todo.filter((item) => {
3875
+ const group = SETTING_GROUPS.find((candidate) => candidate.names.some((name) => item.includes(name.startsWith("S3_") ? "S3_" : name)));
3876
+ return !group || group.missing(env).length > 0;
3877
+ });
3878
+ }
3879
+ // src/commands/settings.ts
3880
+ /** The `media` stack option of the project, from the preset in package.json. */
3881
+ function projectContext(directory) {
3882
+ try {
3883
+ return { media: projectStack(readProject(directory)).media };
3884
+ } catch {
3885
+ return {};
3886
+ }
3887
+ }
3888
+ /** Asks for each field of the groups; returns the `.env` values. */
3889
+ async function askSettings(groups, env) {
3890
+ const values = {};
3891
+ for (const group of groups) {
3892
+ p.log.step(pc.bold(group.title));
3893
+ const answers = {};
3894
+ for (const field of group.fields) {
3895
+ const current = group.current?.(env, field.name);
3896
+ const check = (value) => {
3897
+ const text = (value ?? "").trim();
3898
+ if (!text && (field.optional || field.defaultValue || field.secret && current)) return void 0;
3899
+ return field.validate?.(text) ?? (text ? void 0 : `Enter the ${field.message.toLowerCase()}.`);
3900
+ };
3901
+ let answer;
3902
+ if (field.secret) {
3903
+ answer = unwrap(await p.password({
3904
+ message: current ? `${field.message} (empty keeps the current one)` : field.message,
3905
+ validate: check
3906
+ }));
3907
+ if (!answer && current) answer = current;
3908
+ } else answer = unwrap(await p.text({
3909
+ message: field.message,
3910
+ placeholder: field.placeholder ?? field.defaultValue,
3911
+ initialValue: current,
3912
+ defaultValue: field.defaultValue,
3913
+ validate: check
3914
+ }));
3915
+ answers[field.name] = (answer ?? field.defaultValue ?? "").trim();
3916
+ }
3917
+ Object.assign(values, group.values(answers));
3918
+ }
3919
+ return values;
3920
+ }
3921
+ /** Writes values to a `.env` file, quoted where Compose needs it. Throws for a value it can't write. */
3922
+ function writeSettings(path, values) {
3923
+ const quoted = Object.fromEntries(Object.entries(values).map(([name, value]) => [name, quoteEnvValue(value)]));
3924
+ writeFileSync(path, setEnvValues(readFileSync(path, "utf8"), quoted), { mode: 384 });
3925
+ }
3926
+ /** The production `.env` of a project: `--file`, `.env.production`, or `.env` on a server. */
3927
+ function targetFile(directory, file) {
3928
+ if (file) {
3929
+ const path = join(directory, file);
3930
+ if (!existsSync(path)) fail(`${path} does not exist.`);
3931
+ return path;
3932
+ }
3933
+ const production = join(directory, ".env.production");
3934
+ if (existsSync(production)) return production;
3935
+ const env = join(directory, ".env");
3936
+ if (existsSync(env) && parseProfiles(parseEnv(readFileSync(env, "utf8")).COMPOSE_PROFILES).includes("prod")) return env;
3937
+ fail(`There is no .env.production yet. Write it with ${command("dstack production")}.`);
3938
+ }
3939
+ /** `dstack settings`: ask for the missing production settings and credentials, and save them. */
3940
+ async function settings(options) {
3941
+ const interactive = isInteractive(options.yes);
3942
+ intro("Production settings");
3943
+ const directory = findProjectDirectory(options.dir ?? ".");
3944
+ if (!directory) fail("No DjangoStack project here: run this where compose.yaml and .env are.");
3945
+ const target = targetFile(directory, options.file);
3946
+ const name = relative(directory, target);
3947
+ let assigned;
3948
+ try {
3949
+ assigned = parseAssignments(options.set ?? []);
3950
+ } catch (error) {
3951
+ fail(error.message);
3952
+ }
3953
+ const env = {
3954
+ ...parseEnv(readFileSync(target, "utf8")),
3955
+ ...assigned
3956
+ };
3957
+ const context = projectContext(directory);
3958
+ const missing = settingsToAsk(env, context);
3959
+ let values = assigned;
3960
+ if (interactive) {
3961
+ const groups = settingsToAsk(env, context, options.all);
3962
+ if (!groups.length && !Object.keys(assigned).length) {
3963
+ p.outro(pc.green(`Nothing is missing in ${name}`));
3964
+ return;
3965
+ }
3966
+ if (groups.length && !options.all) p.log.info(`${name} needs ${groups.map((group) => group.title.toLowerCase()).join(", ")}.`);
3967
+ values = {
3968
+ ...assigned,
3969
+ ...await askSettings(groups, env)
3970
+ };
3971
+ } else if (missing.length) fail(`${name} misses ${missing.flatMap((group) => group.missing(env)).join(", ")}. Pass them with --set NAME=value.`);
3972
+ else if (!Object.keys(assigned).length) {
3973
+ p.outro(pc.green(`Nothing is missing in ${name}`));
3974
+ return;
3975
+ }
3976
+ try {
3977
+ writeSettings(target, values);
3978
+ } catch (error) {
3979
+ fail(error.message);
3980
+ }
3981
+ p.log.success(`Saved ${Object.keys(values).join(", ")} in ${pc.bold(name)}`);
3982
+ if (existsSync(join(directory, ".dstack", "deploy.json")) && target.endsWith(".env.production")) p.outro(`Send them to the platform with ${command("dstack deploy env push")}`);
3983
+ else p.outro(pc.green("Done"));
3984
+ }
2435
3985
  // src/commands/production.ts
2436
3986
  async function production(directoryArgument, options) {
2437
3987
  const interactive = isInteractive(options.yes);
@@ -2450,18 +4000,36 @@ async function production(directoryArgument, options) {
2450
4000
  validate: (v) => validateDomain(v ?? "")
2451
4001
  }));
2452
4002
  }
4003
+ const context = projectContext(directory);
2453
4004
  let result;
2454
4005
  try {
2455
- result = productionEnv(readFileSync(envPath, "utf8"), {
4006
+ result = productionEnv$1(readFileSync(envPath, "utf8"), {
2456
4007
  domain,
2457
- emailUrl: options.emailUrl
4008
+ emailUrl: options.emailUrl,
4009
+ media: context.media
2458
4010
  });
2459
4011
  } catch (error) {
2460
4012
  fail(error.message);
2461
4013
  }
2462
4014
  writeFileSync(target, result.text, { mode: 384 });
2463
4015
  p.log.success(`Wrote ${pc.bold(relativeDirectory(target))} with new secrets for the server.`);
2464
- if (result.todo.length) p.note(result.todo.map((item) => `${pc.yellow("•")} ${item}`).join("\n"), "Before the first deploy");
4016
+ let todo = result.todo;
4017
+ const missing = settingsToAsk(parseEnv(result.text), context);
4018
+ if (interactive && missing.length) {
4019
+ if (unwrap(await p.confirm({
4020
+ message: `Set the ${missing.map((group) => group.title.toLowerCase()).join(", ")} now?`,
4021
+ initialValue: true
4022
+ }))) {
4023
+ try {
4024
+ writeSettings(target, await askSettings(missing, parseEnv(result.text)));
4025
+ } catch (error) {
4026
+ fail(error.message);
4027
+ }
4028
+ todo = remainingTodo(todo, parseEnv(readFileSync(target, "utf8")));
4029
+ }
4030
+ }
4031
+ if (todo.some((item) => SETTING_NAMES.some((name) => item.includes(name.startsWith("S3_") ? "S3_" : name)))) todo = [...todo, `Run ${command("dstack settings")} to set them.`];
4032
+ if (todo.length) p.note(todo.map((item) => `${pc.yellow("•")} ${item}`).join("\n"), "Before the first deploy");
2465
4033
  p.note([
2466
4034
  `Copy the project to the server, with ${pc.bold(".env.production")} as ${pc.bold(".env")}, then:`,
2467
4035
  command("docker compose up -d --build"),
@@ -2473,7 +4041,13 @@ async function production(directoryArgument, options) {
2473
4041
  }
2474
4042
  // src/config.ts
2475
4043
  /** The options `config` can switch in an existing project. The rest follow with code generation. */
2476
- const SWITCHABLE = ["storage"];
4044
+ const SWITCHABLE = [
4045
+ "storage",
4046
+ "analytics",
4047
+ "media",
4048
+ "cache",
4049
+ "tasks"
4050
+ ];
2477
4051
  const EMPTY_S3 = Object.fromEntries(Object.keys(LOCAL_S3).map((key) => [key, key === "S3_CREATE_BUCKET" ? "0" : ""]));
2478
4052
  /**
2479
4053
  * Applies a new stack to a project's `.env`. Throws with a one-line reason
@@ -2482,7 +4056,7 @@ const EMPTY_S3 = Object.fromEntries(Object.keys(LOCAL_S3).map((key) => [key, key
2482
4056
  function applyStack(envText, env, from, to) {
2483
4057
  let text = envText;
2484
4058
  const changes = [];
2485
- const profiles = (env.COMPOSE_PROFILES ?? "").split(",").map((profile) => profile.trim()).filter(Boolean);
4059
+ let profiles = (env.COMPOSE_PROFILES ?? "").split(",").map((profile) => profile.trim()).filter(Boolean);
2486
4060
  for (const option of STACK_OPTIONS) {
2487
4061
  const key = option.key;
2488
4062
  if (from[key] === to[key]) continue;
@@ -2493,14 +4067,53 @@ function applyStack(envText, env, from, to) {
2493
4067
  if (profiles.includes("prod")) throw new Error("--storage only changes development; this .env runs the prod profile.");
2494
4068
  const endpoint = env.S3_ENDPOINT_URL ?? "";
2495
4069
  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");
4070
+ profiles = profiles.filter((profile) => profile !== "s3");
4071
+ if (to.storage === "s3") profiles.push("s3");
2498
4072
  text = setEnvValues(text, {
2499
- ...to.storage === "s3" ? LOCAL_S3 : EMPTY_S3,
2500
- COMPOSE_PROFILES: next.join(",")
4073
+ ...to.storage === "s3" ? localS3(envPorts(env).S3_PORT) : EMPTY_S3,
4074
+ COMPOSE_PROFILES: profiles.join(",")
2501
4075
  });
2502
4076
  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
4077
  }
4078
+ if (key === "cache") {
4079
+ text = setEnvValues(text, { CACHE_URL: cacheEnv(to).CACHE_URL ?? "" });
4080
+ changes.push({
4081
+ database: "The cache is a table in the database.",
4082
+ valkey: "The cache is in Valkey (the valkey profile).",
4083
+ off: "The cache is per process; nothing is shared."
4084
+ }[to.cache]);
4085
+ }
4086
+ if (key === "tasks" || key === "cache" && from.tasks === "huey" && to.tasks === "huey") {
4087
+ const { CACHE_URL: _, ...tasks } = cacheEnv(to);
4088
+ const before = parseEnv(text).TASKS_URL;
4089
+ text = setEnvValues(text, tasks);
4090
+ if (before !== tasks.TASKS_URL) changes.push(to.tasks === "off" ? "Tasks run in the request; the worker is off, so there are no daily backups." : `The worker keeps its task queue in ${tasks.TASKS_URL === "db://" ? "the database" : "Valkey"}.`);
4091
+ }
4092
+ if (key === "cache" || key === "tasks") {
4093
+ profiles = profiles.filter((profile) => profile !== "valkey");
4094
+ if (usesValkey(parseEnv(text))) profiles.push("valkey");
4095
+ text = setEnvValues(text, { COMPOSE_PROFILES: profiles.join(",") });
4096
+ }
4097
+ if (key === "analytics") {
4098
+ profiles = profiles.filter((profile) => profile !== "analytics");
4099
+ if (to.analytics === "umami") {
4100
+ profiles.push("analytics");
4101
+ const fresh = umamiEnv(env.DATABASE_URL ?? "", generateProjectSecrets());
4102
+ const kept = Object.fromEntries(Object.entries(fresh).filter(([name]) => name === "UMAMI_URL" || !env[name]));
4103
+ text = setEnvValues(text, {
4104
+ ...kept,
4105
+ COMPOSE_PROFILES: profiles.join(",")
4106
+ });
4107
+ changes.push("Page analytics with Umami are on (the analytics profile).");
4108
+ } else {
4109
+ text = setEnvValues(text, {
4110
+ UMAMI_URL: "",
4111
+ COMPOSE_PROFILES: profiles.join(",")
4112
+ });
4113
+ changes.push("Page analytics are off; Umami's data stays in the database.");
4114
+ }
4115
+ }
4116
+ if (key === "media") changes.push(to.media === "s3" ? "Uploads in production go to S3; dstack settings asks for the S3 settings." : "Uploads in production stay on the server.");
2504
4117
  }
2505
4118
  return {
2506
4119
  env: text,
@@ -2519,6 +4132,7 @@ function setManifestPreset(manifestText, preset) {
2519
4132
  // src/commands/project.ts
2520
4133
  function open(options) {
2521
4134
  if (!has("docker", ["compose", "version"])) fail("Docker Compose v2 was not found. Install Docker: https://docs.docker.com/get-docker/");
4135
+ if (!dockerRunning()) fail(dockerNotRunning());
2522
4136
  try {
2523
4137
  return readProject(options.dir ?? ".");
2524
4138
  } catch (error) {
@@ -2539,7 +4153,7 @@ function exitWith(status) {
2539
4153
  function ensureRunEnv(project) {
2540
4154
  const path = join(project.directory, RUN_ENV);
2541
4155
  if (!existsSync(path)) {
2542
- const result = productionEnv(readFileSync(join(project.directory, ".env"), "utf8"), {
4156
+ const result = productionEnv$1(readFileSync(join(project.directory, ".env"), "utf8"), {
2543
4157
  domain: RUN_DOMAIN,
2544
4158
  emailUrl: "console://"
2545
4159
  });
@@ -2569,11 +4183,33 @@ async function run(options) {
2569
4183
  if (project.localRun) p.log.message(pc.dim(`Caddy signs ${RUN_DOMAIN} with its own local CA, so the browser warns once. The admin password is in ${RUN_ENV}.`));
2570
4184
  p.outro(`Stop it with ${command("dstack stop")}, follow the logs with ${command("dstack logs")}`);
2571
4185
  }
4186
+ /**
4187
+ * Moves the development services to free ports when theirs are in use, in
4188
+ * `.env`, unless the project already runs. compose.yaml from before the
4189
+ * *_PORT variables has fixed ports, so it can only say which one is in use.
4190
+ */
4191
+ async function freeDevPorts(project) {
4192
+ if (runningServices(project).length) return project;
4193
+ const { ports, moves, stuck } = await chooseFreePorts(project.env, project.profiles);
4194
+ const [blocked] = stuck;
4195
+ if (blocked) fail(`Port ${envPorts(project.env)[blocked.name]} of the ${blocked.label} is in use. Stop what uses it.`);
4196
+ if (!moves.length) return project;
4197
+ const composeText = readFileSync(join(project.directory, "compose.yaml"), "utf8");
4198
+ for (const move of moves) {
4199
+ const item = DEV_PORTS.find((candidate) => candidate.label === move.label);
4200
+ if (item && !composeText.includes(`\${${item.name}`)) fail(`Port ${move.from} of the ${move.label} is in use. Stop what uses it, or publish another port in compose.yaml.`);
4201
+ }
4202
+ const envPath = join(project.directory, ".env");
4203
+ writeFileSync(envPath, setEnvValues(readFileSync(envPath, "utf8"), portValues(project.env, ports)), { mode: 384 });
4204
+ for (const move of moves) p.log.info(`${describeMove(move)} Saved in .env.`);
4205
+ return readProject(project.directory);
4206
+ }
2572
4207
  /** `dstack dev`: the dev profile in the foreground, with hot reload. */
2573
4208
  async function dev(options) {
2574
- const project = open(options);
4209
+ let project = open(options);
2575
4210
  if (project.prod) fail(`This .env runs the prod profile. Use ${command("dstack run")}.`);
2576
4211
  intro("Develop");
4212
+ project = await freeDevPorts(project);
2577
4213
  urlsNote(project, "Starting");
2578
4214
  p.log.message(pc.dim("Changes in backend/ and frontend/ reload. Press Ctrl+C to stop."));
2579
4215
  exitWith(compose(project, ["up", ...options.build ? ["--build"] : []]));
@@ -2621,23 +4257,34 @@ async function shell(options) {
2621
4257
  return manage(["shell"], options);
2622
4258
  }
2623
4259
  /**
2624
- * The project and the service that run the backups: the prod worker, on a
4260
+ * The project and how to run a backup command: in the prod worker, on a
2625
4261
  * server or the local run, or the `backup` service of projects from 0.1.
4262
+ * Without a running worker (tasks off, TASK_WORKERS=0) in a one-off worker
4263
+ * container, while the backend runs.
2626
4264
  */
2627
4265
  function backupProject(options) {
2628
4266
  const project = activeProject(open(options));
2629
4267
  if (!project.prod) fail(`Backups run in the prod profile: on the server, or locally after ${command("dstack run")}.`);
2630
4268
  const running = runningServices(project);
2631
4269
  const service = ["backup", project.services.worker].find((name) => running.includes(name));
2632
- if (!service) fail(`The ${project.services.worker} service is not running. Start the project with ${command("dstack run")}.`);
2633
- return {
4270
+ if (service) return {
4271
+ project,
4272
+ run: ["exec", service]
4273
+ };
4274
+ if (project.env.TASK_WORKERS === "0" && running.includes(project.services.backend)) return {
2634
4275
  project,
2635
- service
4276
+ run: [
4277
+ "run",
4278
+ "--rm",
4279
+ "--no-deps",
4280
+ project.services.worker
4281
+ ]
2636
4282
  };
4283
+ fail(`The ${project.services.worker} service is not running. Start the project with ${command("dstack run")}.`);
2637
4284
  }
2638
4285
  /** `dstack backup`: back up the database and the media now, or list the backups. */
2639
4286
  async function backup(options) {
2640
- const { project, service } = backupProject(options);
4287
+ const { project, run } = backupProject(options);
2641
4288
  const args = [
2642
4289
  "python",
2643
4290
  "manage.py",
@@ -2646,9 +4293,9 @@ async function backup(options) {
2646
4293
  if (options.list) args.push("--list");
2647
4294
  else if (!options.media) args.push("--no-media");
2648
4295
  exitWith(compose(project, [
2649
- "exec",
4296
+ run[0],
2650
4297
  "-T",
2651
- service,
4298
+ ...run.slice(1),
2652
4299
  ...args
2653
4300
  ]));
2654
4301
  }
@@ -2673,13 +4320,13 @@ function restoreArgs(name, options) {
2673
4320
  }
2674
4321
  /** `dstack restore <backup>`: restore a database dump, with --media its media too, or a media archive. */
2675
4322
  async function restore(name, options) {
2676
- const { project, service } = backupProject(options);
4323
+ const { project, run } = backupProject(options);
2677
4324
  const args = restoreArgs(name, options);
2678
4325
  if (!options.yes && !tty()) fail("Pass --yes to restore without a terminal.");
2679
4326
  exitWith(compose(project, [
2680
- "exec",
4327
+ run[0],
2681
4328
  ...options.yes ? ["-T"] : [],
2682
- service,
4329
+ ...run.slice(1),
2683
4330
  ...args
2684
4331
  ]));
2685
4332
  }
@@ -2917,19 +4564,15 @@ async function config(options) {
2917
4564
  intro("Configure");
2918
4565
  const current = projectStack(project);
2919
4566
  const next = { ...current };
2920
- for (const option of STACK_OPTIONS) {
2921
- const key = option.key;
2922
- if (options.fromCli(key)) next[key] = options[key];
2923
- else if (interactive && SWITCHABLE.includes(key)) next[key] = unwrap(await p.select({
2924
- message: option.message,
2925
- initialValue: current[key],
2926
- options: option.values.map((value) => ({
2927
- value: value.value,
2928
- label: value.label,
2929
- hint: value.generated ? value.hint : "coming soon",
2930
- disabled: !value.generated && value.value !== current[key]
2931
- }))
2932
- }));
4567
+ for (const key of PROMPT_ORDER) if (options.fromCli(key)) next[key] = options[key];
4568
+ else if (interactive && SWITCHABLE.includes(key)) next[key] = unwrap(await p.select({
4569
+ message: stackOption(key).message,
4570
+ initialValue: current[key],
4571
+ options: choices(key, next)
4572
+ }));
4573
+ if (next.analytics === "umami" && current.analytics !== "umami") {
4574
+ const compose = join(project.directory, "compose.yaml");
4575
+ 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.");
2933
4576
  }
2934
4577
  const envPath = join(project.directory, ".env");
2935
4578
  let result;
@@ -2942,11 +4585,13 @@ async function config(options) {
2942
4585
  p.outro("Nothing changed");
2943
4586
  return;
2944
4587
  }
2945
- writeFileSync(envPath, result.env, { mode: 384 });
4588
+ const envText = readFileSync(envPath, "utf8");
4589
+ if (result.env !== envText) writeFileSync(envPath, result.env, { mode: 384 });
2946
4590
  const manifestPath = join(project.directory, "package.json");
2947
4591
  if (existsSync(manifestPath)) writeFileSync(manifestPath, setManifestPreset(readFileSync(manifestPath, "utf8"), encodePreset(next)));
2948
4592
  for (const change of result.changes) p.log.success(change);
2949
- p.outro(`Restart to apply it: ${command("dstack stop")}, then ${command("dstack dev")}`);
4593
+ if (result.env === envText) p.outro(pc.green("Done"));
4594
+ else p.outro(`Restart to apply it: ${command("dstack stop")}, then ${command("dstack dev")}`);
2950
4595
  }
2951
4596
  // src/program.ts
2952
4597
  /** Commands that print machine-readable output, so no update notice. */
@@ -3001,7 +4646,7 @@ function createProgram() {
3001
4646
  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
4647
  program.command("types").description("Generate the frontend's API types from the backend's schema").addOption(dirOption()).action(types);
3003
4648
  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");
4649
+ const deployCommand = program.command("deploy").description("Deploy to Coolify, Dokploy, Bunny or a server over SSH, and show the status");
3005
4650
  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
4651
  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
4652
  const seen = /* @__PURE__ */ new Set();
@@ -3023,6 +4668,7 @@ function createProgram() {
3023
4668
  }));
3024
4669
  for (const flag of stackFlags()) presetCommand.addOption(flag);
3025
4670
  program.command("production").description("Write .env.production for a server").argument("[directory]", "The project directory", ".").option("--domain <domain>", "The domain of the site, such as example.com").option("--email-url <url>", "The SMTP server, such as smtp+tls://user:pass@host:587").option("--force", "Overwrite an existing .env.production").option("-y, --yes", "Don't ask; fail when --domain is missing").action(production);
4671
+ program.command("settings").description("Ask for the missing production settings and credentials, and save them").addOption(dirOption()).option("--file <file>", "The file to change (default: .env.production, or .env on a server)").option("--set <NAME=value>", "Set a value without asking, such as --set ADMIN_EMAIL=you@example.com", (value, previous = []) => [...previous, value]).option("--all", "Ask for every setting, also the ones that are set").option("-y, --yes", "Don't ask; fail when a setting is still missing").action(settings);
3026
4672
  program.command("doctor").description("Check a project and its services").argument("[directory]", "The project directory", ".").action(doctor);
3027
4673
  program.command("secret").description("Print a random secret").argument("[bytes]", "The number of random bytes", positiveInteger, 32).action((bytes) => console.log(generateSecret(bytes)));
3028
4674
  tab(program, { completionCommandName: "completion" });