@django-stack/cli 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -1,13 +1,1484 @@
1
1
  #!/usr/bin/env node
2
- import { D as setEnvValues, E as parseEnv, S as validateDomain, T as runDoctor, _ as toProjectName, a as decodePreset, b as CLI_VERSION, c as resolvePreset, d as LOCAL_S3, f as lockBackend, g as timeZones, h as scaffold, i as applies, l as stackOption, m as relativeDirectory, n as NAMED_PRESETS, o as encodePreset, r as STACK_OPTIONS, s as pendingChoices, t as DEFAULT_STACK, v as validateProjectName, w as generateSecret, x as productionEnv, y as validateTimeZone } from "./stack-OTrWHXrO.js";
3
- import { execFileSync, spawn, spawnSync } from "node:child_process";
4
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { D as parseEnv, E as checkTools, O as setEnvValues, S as validateDomain, T as runDoctor, _ as toProjectName, a as decodePreset, b as CLI_VERSION, c as resolvePreset, d as LOCAL_S3, f as lockBackend, g as timeZones, h as scaffold, i as applies, l as stackOption, m as relativeDirectory, n as NAMED_PRESETS, o as encodePreset, r as STACK_OPTIONS, s as pendingChoices, t as DEFAULT_STACK, v as validateProjectName, w as generateSecret, x as productionEnv, y as validateTimeZone } from "./stack-CTyqYWCV.js";
3
+ import { createRequire } from "node:module";
4
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { basename, delimiter, dirname, join, resolve } from "node:path";
6
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
6
7
  import pc from "picocolors";
7
8
  import tab from "@bomb.sh/tab/commander";
8
9
  import * as p from "@clack/prompts";
9
10
  import { Command, InvalidArgumentError, Option } from "commander";
10
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) {
15
+ return {
16
+ label: `${name} URL`,
17
+ placeholder,
18
+ normalize: normalizeUrl
19
+ };
20
+ }
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}`;
32
+ }
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;
68
+ }
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;
78
+ }
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)
91
+ });
92
+ } catch (error) {
93
+ throw new CoolifyError(`Could not reach Coolify at ${this.url}: ${error.message}`, 0);
94
+ }
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;
102
+ }
103
+ }
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 = "";
113
+ try {
114
+ const body = JSON.parse(text);
115
+ message = body.message ?? "";
116
+ if (body.errors) message += ` ${Object.values(body.errors).flat().join(" ")}`;
117
+ } catch {
118
+ message = text.slice(0, 200);
119
+ }
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";
129
+ }
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";
136
+ }
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);
143
+ } catch {
144
+ return logs.split("\n").map((text) => ({
145
+ text,
146
+ stderr: false
147
+ }));
148
+ }
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
+ })));
154
+ }
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);
160
+ } catch {
161
+ value = null;
162
+ }
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
+ }
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
+ };
182
+ }
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);
186
+ }
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
+ const systemSsh = (args, input) => new Promise((resolve, reject) => {
359
+ const child = spawn("ssh", args, { stdio: [
360
+ "pipe",
361
+ "pipe",
362
+ "pipe"
363
+ ] });
364
+ const stdout = [];
365
+ const stderr = [];
366
+ child.stdout.on("data", (chunk) => stdout.push(chunk));
367
+ child.stderr.on("data", (chunk) => stderr.push(chunk));
368
+ child.on("error", (error) => reject(/* @__PURE__ */ new Error(error.code === "ENOENT" ? "There is no ssh command here. Install OpenSSH." : `Could not run ssh: ${error.message}`)));
369
+ child.on("close", (code) => resolve({
370
+ code: code ?? 1,
371
+ stdout: Buffer.concat(stdout).toString("utf8"),
372
+ stderr: Buffer.concat(stderr).toString("utf8")
373
+ }));
374
+ child.stdin.on("error", () => {});
375
+ child.stdin.end(input);
376
+ });
377
+ const USER = /^[a-z_][\w.-]*$/i;
378
+ const HOST = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?)(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$|^\[[0-9a-f:.]+\]$/i;
379
+ /** Reads deploy@example.com, example.com:2222 or ssh://deploy@example.com:2222. */
380
+ function parseTarget(value) {
381
+ const match = value.trim().replace(/^ssh:\/\//i, "").replace(/\/$/, "").match(/^(?:([^@\s]+)@)?(\[[^\]]+\]|[^:@\s]+)(?::(\d+))?$/);
382
+ const reason = `${value} is not an SSH target. Enter one such as deploy@example.com or deploy@example.com:2222.`;
383
+ if (!match) throw new Error(reason);
384
+ const [, user, host = "", port] = match;
385
+ if (user && !USER.test(user) || !HOST.test(host)) throw new Error(reason);
386
+ const number = port ? Number(port) : void 0;
387
+ if (number !== void 0 && (number < 1 || number > 65535)) throw new Error(reason);
388
+ return {
389
+ user,
390
+ host,
391
+ port: number
392
+ };
393
+ }
394
+ function formatTarget(target) {
395
+ return `ssh://${target.user ? `${target.user}@` : ""}${target.host}${target.port ? `:${target.port}` : ""}`;
396
+ }
397
+ /** Quotes a value for the remote POSIX shell. */
398
+ function shellQuote(value) {
399
+ return `'${value.replace(/'/g, `'\\''`)}'`;
400
+ }
401
+ /** A Compose project name: lower case letters, digits, dashes and underscores. */
402
+ function composeName(name) {
403
+ return name.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^[^a-z0-9]+|-+$/g, "") || "site";
404
+ }
405
+ /** A deploy id that sorts by time: 20261105-183000-abc1234. */
406
+ function deployId(date, suffix) {
407
+ const pad = (value) => String(value).padStart(2, "0");
408
+ return `${`${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}`}-${`${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}`}-${suffix}`;
409
+ }
410
+ const ID = /^[\w.-]+$/;
411
+ /** The status file: building, finished or failed, and whether the deploy's process lives. */
412
+ function recordStatus(status, alive) {
413
+ const value = status.trim();
414
+ if (value === "finished") return "finished";
415
+ if (value === "failed") return "failed";
416
+ if (value === "queued") return alive ? "queued" : "failed";
417
+ if (value === "building") return alive ? "building" : "failed";
418
+ return "failed";
419
+ }
420
+ /** `docker compose ps --format json` prints an array (older) or one object per line (newer). */
421
+ function parseComposePs(output) {
422
+ const text = output.trim();
423
+ if (!text) return [];
424
+ if (text.startsWith("[")) return JSON.parse(text);
425
+ return text.split("\n").filter((line) => line.trim()).map((line) => JSON.parse(line));
426
+ }
427
+ function composeState(containers) {
428
+ if (!containers.length) return {
429
+ state: "stopped",
430
+ detail: "no containers"
431
+ };
432
+ const running = containers.filter((item) => item.State === "running");
433
+ const unhealthy = containers.filter((item) => item.Health === "unhealthy" || item.State === "restarting");
434
+ const summary = `${running.length}/${containers.length} running`;
435
+ if (!running.length) return {
436
+ state: "stopped",
437
+ detail: summary
438
+ };
439
+ if (unhealthy.length) return {
440
+ state: "degraded",
441
+ detail: `${summary}, unhealthy: ${unhealthy.map((item) => item.Service).join(", ")}`
442
+ };
443
+ if (running.length < containers.length) return {
444
+ state: "degraded",
445
+ detail: `${summary}, not running: ${containers.filter((item) => item.State !== "running").map((item) => item.Service).join(", ")}`
446
+ };
447
+ return {
448
+ state: "running",
449
+ detail: summary
450
+ };
451
+ }
452
+ /** Splits the output of a remote command whose parts are separated by MARK lines. */
453
+ const MARK = "--dstack-8f2c--";
454
+ function parts(output) {
455
+ return output.split(`\n${MARK}\n`);
456
+ }
457
+ /**
458
+ * The script that runs a deploy on the server, detached. It builds and
459
+ * starts the release, moves `current` to it on success and removes the
460
+ * oldest releases beyond KEEP_RELEASES.
461
+ */
462
+ function deployScript(path, project, id, release, force) {
463
+ const q = shellQuote;
464
+ const compose = `docker compose -p ${q(project)}`;
465
+ return `#!/bin/sh
466
+ set -u
467
+ app=${q(path)}
468
+ status="$app/deploys/${id}.status"
469
+ trap 'rm -rf "$app/deploy.lock"' EXIT
470
+ cd "$app/releases/${release}" || { echo failed > "$status"; exit 1; }
471
+ echo building > "$status"
472
+ echo "Deploy ${id}: release ${release}"
473
+ ${force ? `${compose} build --no-cache --pull && ` : ""}${compose} up -d --build --remove-orphans --wait --wait-timeout 600
474
+ code=$?
475
+ if [ "$code" -ne 0 ]; then
476
+ echo "docker compose exited with $code"
477
+ ${compose} ps --all
478
+ echo failed > "$status"
479
+ exit "$code"
480
+ fi
481
+ ln -sfn "releases/${release}" "$app/current"
482
+ cd "$app/releases"
483
+ ls -1 | sort -r | tail -n +6 | while read -r old; do
484
+ [ "$old" = ${q(release)} ] || rm -rf -- "$old"
485
+ done
486
+ echo "Started release ${release}"
487
+ echo finished > "$status"
488
+ `;
489
+ }
490
+ var SshClient = class {
491
+ run;
492
+ git;
493
+ target;
494
+ constructor(target, run = systemSsh, git = defaultGit) {
495
+ this.run = run;
496
+ this.git = git;
497
+ this.target = parseTarget(target);
498
+ }
499
+ get destination() {
500
+ return `${this.target.user ? `${this.target.user}@` : ""}${this.target.host.replace(/^\[|\]$/g, "")}`;
501
+ }
502
+ /** Runs a POSIX shell script on the server; fails with a one-line reason. */
503
+ async sh(script, input) {
504
+ const args = [
505
+ "-o",
506
+ "BatchMode=yes",
507
+ "-o",
508
+ "ConnectTimeout=15",
509
+ ...this.target.port ? ["-p", String(this.target.port)] : [],
510
+ "--",
511
+ this.destination,
512
+ `sh -c ${shellQuote(script)}`
513
+ ];
514
+ const result = await this.run(args, input);
515
+ if (result.code === 0) return result.stdout;
516
+ throw new Error(this.errorMessage(result));
517
+ }
518
+ errorMessage(result) {
519
+ const stderr = result.stderr.trim();
520
+ const where = formatTarget(this.target);
521
+ if (/REMOTE HOST IDENTIFICATION HAS CHANGED/i.test(stderr)) return `The host key of ${where} changed. If you reinstalled the server, remove the old key with ssh-keygen -R ${this.target.host}; otherwise do not connect.`;
522
+ if (/Host key verification failed|No .* host key is known/i.test(stderr)) return `${where} is not in your known_hosts yet. Connect once with ssh ${this.destination}${this.target.port ? ` -p ${this.target.port}` : ""}, check the fingerprint and answer yes, then try again.`;
523
+ if (/Permission denied/i.test(stderr)) return `${where} refused your SSH key. Add it with ssh-copy-id ${this.destination}, or load it into the agent with ssh-add.`;
524
+ if (result.code === 255) return `Could not connect to ${where}: ${stderr.split("\n").at(-1) || "ssh failed"}`;
525
+ const last = (stderr || result.stdout.trim()).split("\n").at(-1) ?? "";
526
+ return `The command on ${where} failed (exit ${result.code})${last ? `: ${last}` : "."}`;
527
+ }
528
+ /** Checks the connection and Docker; returns their versions. */
529
+ async verify() {
530
+ const output = await this.sh(`
531
+ command -v docker >/dev/null 2>&1 || { echo missing:docker; exit 0; }
532
+ docker compose version --short 2>/dev/null || echo missing:compose
533
+ docker info --format '{{.ServerVersion}}' 2>/dev/null || echo missing:permission
534
+ `);
535
+ if (output.includes("missing:docker")) throw new Error(`${formatTarget(this.target)} has no Docker. Install Docker Engine with the Compose plugin: https://docs.docker.com/engine/install/`);
536
+ if (output.includes("missing:compose")) throw new Error(`${formatTarget(this.target)} has Docker without the Compose plugin. Install docker-compose-plugin: https://docs.docker.com/compose/install/linux/`);
537
+ if (output.includes("missing:permission")) throw new Error(`${this.destination} may not use Docker on the server. Run sudo usermod -aG docker ${this.target.user ?? "$USER"} there and connect again.`);
538
+ const [compose = "", engine = ""] = output.trim().split("\n");
539
+ return `Docker ${engine.trim()}, Compose ${compose.trim().replace(/^v/, "")}`;
540
+ }
541
+ async setup(context) {
542
+ const { ask, options } = context;
543
+ let path = options.path ?? `/srv/${composeName(context.name)}`;
544
+ if (path.startsWith("~/")) path = `${(await this.sh("printf %s \"$HOME\"")).trim()}/${path.slice(2)}`;
545
+ if (!path.startsWith("/")) throw new Error(`Give --path as an absolute path, such as /srv/${composeName(context.name)}.`);
546
+ path = path.replace(/\/+$/, "");
547
+ const q = shellQuote;
548
+ if (!(await this.sh(`
549
+ if mkdir -p ${q(`${path}/releases`)} ${q(`${path}/shared`)} ${q(`${path}/deploys`)} 2>/dev/null && [ -w ${q(path)} ]; then echo ok; fi
550
+ `)).includes("ok")) {
551
+ const home = (await this.sh("printf %s \"$HOME\"")).trim();
552
+ throw new Error(`${this.destination} cannot write to ${path} on the server. Create it there with sudo mkdir -p ${path} && sudo chown ${this.target.user ?? "$USER"} ${path}, or pass a directory you own, such as --path ${home}/${composeName(context.name)}.`);
553
+ }
554
+ ask.step(`Created ${path} on the server`);
555
+ const project = composeName(context.name);
556
+ const link = {
557
+ project: path,
558
+ environment: "production",
559
+ application: project
560
+ };
561
+ await this.pushEnv({
562
+ provider: "ssh",
563
+ url: formatTarget(this.target),
564
+ ...link
565
+ }, context.env);
566
+ ask.step("Uploaded the variables as shared/.env");
567
+ return link;
568
+ }
569
+ async pushEnv(link, env) {
570
+ const text = Object.entries(env).map(([key, value]) => `${key}=${envValue(value)}`).join("\n");
571
+ const file = shellQuote(`${link.project}/shared/.env`);
572
+ await this.sh(`umask 077 && cat > ${file}.new && chmod 600 ${file}.new && mv ${file}.new ${file}`, `${text}\n`);
573
+ }
574
+ async deploy(link, options) {
575
+ const { directory } = options;
576
+ const [commit = "", message = ""] = this.git([
577
+ "-C",
578
+ directory,
579
+ "log",
580
+ "-1",
581
+ "--format=%h%n%s"
582
+ ]).toString("utf8").split("\n");
583
+ const id = deployId(/* @__PURE__ */ new Date(), commit || "local");
584
+ await this.lock(link, id);
585
+ const archive = this.git([
586
+ "-C",
587
+ directory,
588
+ "archive",
589
+ "--format=tar",
590
+ "HEAD"
591
+ ]);
592
+ const release = shellQuote(`${link.project}/releases/${id}`);
593
+ await this.sh(`mkdir -p ${release} && tar -x -C ${release} && ln -sfn ../../shared/.env ${release}/.env`, archive);
594
+ await this.start(link, {
595
+ id,
596
+ release: id,
597
+ commit,
598
+ message,
599
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
600
+ }, options.force);
601
+ return id;
602
+ }
603
+ async rollback(link, to) {
604
+ const deployments = await this.records(link, 50);
605
+ const current = (await this.sh(`readlink ${shellQuote(`${link.project}/current`)} || true`)).trim().replace(/^releases\//, "");
606
+ const releases = new Set((await this.sh(`ls -1 ${shellQuote(`${link.project}/releases`)}`)).split("\n").filter(Boolean));
607
+ let target;
608
+ if (to) {
609
+ target = deployments.find((item) => item.record.id === to)?.record;
610
+ if (!target) throw new Error(`There is no deploy ${to} on the server.`);
611
+ } else {
612
+ target = deployments.find((item) => item.status === "finished" && item.record.release !== current && releases.has(item.record.release))?.record;
613
+ if (!target) throw new Error("There is no earlier release on the server to roll back to.");
614
+ }
615
+ if (!releases.has(target.release)) throw new Error(`The release of ${target.id} is no longer on the server; only the last 5 are kept.`);
616
+ const id = deployId(/* @__PURE__ */ new Date(), "rollback");
617
+ await this.lock(link, id);
618
+ await this.start(link, {
619
+ id,
620
+ release: target.release,
621
+ commit: target.commit,
622
+ message: `Rollback to ${target.id}`,
623
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
624
+ rollbackOf: target.id
625
+ }, false);
626
+ return id;
627
+ }
628
+ /** Takes the server's deploy lock, or fails when a deploy is running. */
629
+ async lock(link, id) {
630
+ const lock = shellQuote(`${link.project}/deploy.lock`);
631
+ const output = await this.sh(`
632
+ if mkdir ${lock} 2>/dev/null; then echo ${id} > ${lock}/id; echo ok; exit 0; fi
633
+ pid=$(cat ${lock}/pid 2>/dev/null)
634
+ if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then echo "busy:$(cat ${lock}/id 2>/dev/null)"; exit 0; fi
635
+ rm -rf ${lock} && mkdir ${lock} && echo ${id} > ${lock}/id && echo ok
636
+ `);
637
+ const busy = output.match(/busy:(\S*)/);
638
+ if (busy) throw new Error(`Deploy ${busy[1]} is still running on the server. Follow it with dstack deploy status --watch.`);
639
+ if (!output.includes("ok")) throw new Error("Could not take the deploy lock on the server.");
640
+ }
641
+ async start(link, record, force) {
642
+ if (!ID.test(record.id) || !ID.test(record.release)) throw new Error(`Invalid deploy id ${record.id}.`);
643
+ const deploys = `${link.project}/deploys`;
644
+ const q = shellQuote;
645
+ const script = deployScript(link.project, link.application, record.id, record.release, force);
646
+ await this.sh(`
647
+ cd ${q(deploys)} || exit 1
648
+ cat > ${q(`${record.id}.sh`)}
649
+ printf '%s\\n' ${q(JSON.stringify(record))} > ${q(`${record.id}.json`)}
650
+ echo queued > ${q(`${record.id}.status`)}
651
+ nohup sh ${q(`${record.id}.sh`)} > ${q(`${record.id}.log`)} 2>&1 < /dev/null &
652
+ echo $! > ${q(`${record.id}.pid`)}
653
+ echo $! > ${q(`${link.project}/deploy.lock/pid`)}
654
+ `, script);
655
+ }
656
+ /** The newest deploy records first, with their status. */
657
+ async records(link, limit) {
658
+ const output = await this.sh(`
659
+ cd ${shellQuote(`${link.project}/deploys`)} 2>/dev/null || exit 0
660
+ for file in $(ls -1 *.json 2>/dev/null | sort -r | head -n ${Math.max(1, Math.floor(limit))}); do
661
+ id="\${file%.json}"
662
+ cat "$file"
663
+ pid=$(cat "$id.pid" 2>/dev/null)
664
+ if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then alive=1; else alive=0; fi
665
+ printf '%s %s %s\\n' "$(cat "$id.status" 2>/dev/null || echo failed)" "$alive" "$(date -u -r "$id.status" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)"
666
+ echo ${MARK}
667
+ done
668
+ `);
669
+ const items = [];
670
+ for (const part of parts(`\n${output}`)) {
671
+ const lines = part.trim().split("\n");
672
+ if (lines.length < 2) continue;
673
+ try {
674
+ const record = JSON.parse(lines[0] ?? "");
675
+ const [status = "", alive = "0", updatedAt] = (lines[1] ?? "").split(" ");
676
+ items.push({
677
+ record,
678
+ status: recordStatus(status, alive === "1"),
679
+ updatedAt: updatedAt || void 0
680
+ });
681
+ } catch {}
682
+ }
683
+ return items;
684
+ }
685
+ async deployment(link, id) {
686
+ if (!ID.test(id)) throw new Error(`Invalid deploy id ${id}.`);
687
+ const deploys = shellQuote(`${link.project}/deploys`);
688
+ const output = await this.sh(`
689
+ cd ${deploys} || exit 1
690
+ [ -f ${shellQuote(`${id}.json`)} ] || { echo missing; exit 0; }
691
+ cat ${shellQuote(`${id}.json`)}
692
+ pid=$(cat ${shellQuote(`${id}.pid`)} 2>/dev/null)
693
+ if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then alive=1; else alive=0; fi
694
+ printf '%s %s %s\\n' "$(cat ${shellQuote(`${id}.status`)} 2>/dev/null || echo failed)" "$alive" "$(date -u -r ${shellQuote(`${id}.status`)} +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)"
695
+ echo ${MARK}
696
+ cat ${shellQuote(`${id}.log`)} 2>/dev/null
697
+ `);
698
+ if (output.trim() === "missing") throw new Error(`There is no deploy ${id} on the server.`);
699
+ const [head = "", log = ""] = output.split(`\n${MARK}\n`);
700
+ const [json = "{}", state = ""] = head.split("\n");
701
+ const record = JSON.parse(json);
702
+ const [status = "", alive = "0", updatedAt] = state.split(" ");
703
+ return {
704
+ ...toDeployment(record, recordStatus(status, alive === "1"), updatedAt || void 0),
705
+ log: log ? log.replace(/\n$/, "").split("\n").map((text) => ({
706
+ text,
707
+ stderr: false
708
+ })) : []
709
+ };
710
+ }
711
+ async deployments(link, limit) {
712
+ return (await this.records(link, limit)).map((item) => ({
713
+ ...toDeployment(item.record, item.status, item.updatedAt),
714
+ log: []
715
+ }));
716
+ }
717
+ async status(link) {
718
+ const [ps = "", site = ""] = parts(await this.sh(`
719
+ docker compose -p ${shellQuote(link.application)} ps --all --format json
720
+ echo ${MARK}
721
+ sed -n 's/^SITE_URL=//p' ${shellQuote(`${link.project}/shared/.env`)} 2>/dev/null
722
+ `));
723
+ const { state, detail } = composeState(parseComposePs(ps));
724
+ const domain = site.trim().replace(/^["']|["']$/g, "");
725
+ return {
726
+ state,
727
+ detail,
728
+ domains: domain ? [domain] : []
729
+ };
730
+ }
731
+ };
732
+ function toDeployment(record, status, updatedAt) {
733
+ return {
734
+ id: record.id,
735
+ status,
736
+ commit: record.commit,
737
+ message: record.message,
738
+ createdAt: record.createdAt,
739
+ updatedAt
740
+ };
741
+ }
742
+ /**
743
+ * A value for a Compose .env line: bare when safe, else single-quoted (taken
744
+ * literally), or double-quoted with escapes when it holds a single quote.
745
+ */
746
+ function envValue(value) {
747
+ if (/^[\w@%+=:,./-]*$/.test(value)) return value;
748
+ if (!value.includes("'") && !value.includes("\n")) return `'${value}'`;
749
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\$/g, "$$$$").replace(/\n/g, "\\n")}"`;
750
+ }
751
+ function defaultGit(args) {
752
+ return execFileSync("git", args, {
753
+ maxBuffer: 1073741824,
754
+ stdio: [
755
+ "ignore",
756
+ "pipe",
757
+ "pipe"
758
+ ]
759
+ });
760
+ }
761
+ const ssh = {
762
+ id: "ssh",
763
+ name: "SSH server",
764
+ auth: { kind: "ssh" },
765
+ source: "local",
766
+ target: {
767
+ label: "Server",
768
+ placeholder: "deploy@example.com",
769
+ normalize: (value) => formatTarget(parseTarget(value))
770
+ },
771
+ platformEnv: {},
772
+ setupFlags: [{
773
+ name: "path",
774
+ description: "The project directory on the server (default: /srv/<project>)"
775
+ }],
776
+ connect: (target) => new SshClient(target)
777
+ };
778
+ // src/deploy/dokploy.ts
779
+ var DokployError = class extends Error {
780
+ status;
781
+ constructor(message, status) {
782
+ super(message);
783
+ this.status = status;
784
+ }
785
+ };
786
+ var DokployApi = class {
787
+ url;
788
+ token;
789
+ fetchImpl;
790
+ constructor(url, token, fetchImpl = fetch) {
791
+ this.url = url;
792
+ this.token = token;
793
+ this.fetchImpl = fetchImpl;
794
+ }
795
+ async request(method, procedure, input) {
796
+ let path = `${this.url}/api/${procedure}`;
797
+ if (method === "GET" && input) {
798
+ const query = new URLSearchParams();
799
+ for (const [key, value] of Object.entries(input)) if (value !== void 0) query.set(key, String(value));
800
+ path += `?${query}`;
801
+ }
802
+ let response;
803
+ try {
804
+ response = await this.fetchImpl(path, {
805
+ method,
806
+ headers: {
807
+ "x-api-key": this.token,
808
+ Accept: "application/json",
809
+ ...method === "POST" ? { "Content-Type": "application/json" } : {}
810
+ },
811
+ body: method === "POST" ? JSON.stringify(input ?? {}) : void 0,
812
+ signal: AbortSignal.timeout(3e4)
813
+ });
814
+ } catch (error) {
815
+ throw new DokployError(`Could not reach Dokploy at ${this.url}: ${error.message}`, 0);
816
+ }
817
+ const text = await response.text();
818
+ if (!response.ok) throw new DokployError(errorMessage(response.status, text, this.url), response.status);
819
+ if (!text) return void 0;
820
+ try {
821
+ return JSON.parse(text);
822
+ } catch {
823
+ return text;
824
+ }
825
+ }
826
+ get = (procedure, input) => this.request("GET", procedure, input);
827
+ post = (procedure, input) => this.request("POST", procedure, input);
828
+ };
829
+ function errorMessage(status, text, url) {
830
+ if (status === 401) return `Dokploy at ${url} rejected the API key. Run dstack deploy login to enter a new one.`;
831
+ if (status === 403) return "The API key may not do this. Create it as a user who may manage projects in Dokploy.";
832
+ if (status === 404 && text.includes("<html")) return `${url} does not answer like Dokploy; check the URL.`;
833
+ let message = "";
834
+ try {
835
+ const body = JSON.parse(text);
836
+ message = body.message ?? body.error?.message ?? "";
837
+ } catch {
838
+ message = text.slice(0, 200);
839
+ }
840
+ return `Dokploy answered ${status}${message ? `: ${message.trim()}` : "."}`;
841
+ }
842
+ /** A deployment's status: running, done, error or cancelled in Dokploy. */
843
+ function dokployDeploymentStatus(value) {
844
+ if (value === "done") return "finished";
845
+ if (value === "running") return "building";
846
+ if (value === "queued" || value === "idle") return "queued";
847
+ if (value === "cancelled") return "cancelled";
848
+ return "failed";
849
+ }
850
+ /** The service state from the compose status, which tracks the last deploy. */
851
+ function dokployAppState(value) {
852
+ if (value === "done") return "running";
853
+ if (value === "error") return "degraded";
854
+ if (value === "idle") return "stopped";
855
+ return "unknown";
856
+ }
857
+ /** A .env text for Dokploy, which writes it next to compose.yaml. */
858
+ function envText(env) {
859
+ return Object.entries(env).map(([key, value]) => `${key}=${envValue(value)}`).join("\n");
860
+ }
861
+ function composeUrl(api, link) {
862
+ return `${api.url}/dashboard/project/${link.project}/environment/${link.environment}/services/compose/${link.application}`;
863
+ }
864
+ /** Finds a name or id in a list. */
865
+ function find(items, wanted, id, name) {
866
+ return items.find((item) => id(item) === wanted) ?? items.find((item) => name(item) === wanted);
867
+ }
868
+ const LOCAL_SERVER = "dokploy";
869
+ var DokployClient = class {
870
+ api;
871
+ constructor(url, token, fetchImpl) {
872
+ this.api = new DokployApi(url, token, fetchImpl);
873
+ }
874
+ async verify() {
875
+ await this.api.get("project.all");
876
+ const version = await this.api.get("settings.getDokployVersion").catch(() => void 0);
877
+ return typeof version === "string" && version.trim() ? version.trim() : "(version unknown)";
878
+ }
879
+ async setup(context) {
880
+ const { ask, options } = context;
881
+ if (!context.repository) throw new Error("Dokploy builds from a git repository; pass --repository.");
882
+ const api = this.api;
883
+ const servers = await api.get("server.all").catch(() => []) ?? [];
884
+ let serverId;
885
+ if (options.server && options.server !== LOCAL_SERVER) {
886
+ const server = find(servers, options.server, (item) => item.serverId, (item) => item.name);
887
+ if (!server) throw new Error(`Dokploy has no server ${options.server}.`);
888
+ serverId = server.serverId;
889
+ } else if (!options.server && servers.length && ask.interactive) {
890
+ const choice = await ask.select("Server to deploy to", [{
891
+ value: LOCAL_SERVER,
892
+ label: "The Dokploy server"
893
+ }, ...servers.map((item) => ({
894
+ value: item.serverId,
895
+ label: item.name,
896
+ hint: item.ipAddress
897
+ }))], LOCAL_SERVER);
898
+ serverId = choice === LOCAL_SERVER ? void 0 : choice;
899
+ }
900
+ const projects = await api.get("project.all") ?? [];
901
+ const projectName = options.project ?? context.name;
902
+ let project = find(projects, projectName, (item) => item.projectId, (item) => item.name);
903
+ if (!options.project && ask.interactive && projects.length) {
904
+ const NEW = "";
905
+ const id = await ask.select("Dokploy project", [...project ? [] : [{
906
+ value: NEW,
907
+ label: `Create ${projectName}`
908
+ }], ...projects.map((item) => ({
909
+ value: item.projectId,
910
+ label: item.name,
911
+ hint: item.description ?? void 0
912
+ }))], project?.projectId ?? NEW);
913
+ project = id === NEW ? void 0 : find(projects, id, (item) => item.projectId, (item) => item.name);
914
+ }
915
+ if (!project) {
916
+ const created = await api.post("project.create", {
917
+ name: projectName,
918
+ description: "DjangoStack"
919
+ });
920
+ const projectId = created?.project?.projectId ?? created?.projectId;
921
+ if (!projectId) throw new Error("Dokploy created the project but did not return its id.");
922
+ project = {
923
+ projectId,
924
+ name: projectName
925
+ };
926
+ ask.step(`Created the Dokploy project ${projectName}`);
927
+ }
928
+ const environmentName = options.environment ?? "production";
929
+ let environment = find(await api.get("environment.byProjectId", { projectId: project.projectId }).catch((error) => {
930
+ if (error.status === 404) throw new Error("This Dokploy has no environments; update Dokploy to a version with environments.");
931
+ throw error;
932
+ }) ?? [], environmentName, (item) => item.environmentId, (item) => item.name);
933
+ if (!environment) {
934
+ const environmentId = (await api.post("environment.create", {
935
+ name: environmentName,
936
+ projectId: project.projectId
937
+ }))?.environmentId ?? (await api.get("environment.byProjectId", { projectId: project.projectId })).find((item) => item.name === environmentName)?.environmentId;
938
+ if (!environmentId) throw new Error("Dokploy created the environment but did not return its id.");
939
+ environment = {
940
+ environmentId,
941
+ name: environmentName
942
+ };
943
+ ask.step(`Created the environment ${environmentName}`);
944
+ }
945
+ let composeId = (await api.post("compose.create", {
946
+ name: context.name,
947
+ environmentId: environment.environmentId,
948
+ projectId: project.projectId,
949
+ composeType: "docker-compose",
950
+ description: "DjangoStack",
951
+ ...serverId ? { serverId } : {}
952
+ }))?.composeId;
953
+ if (!composeId) composeId = ((await api.get("environment.one", { environmentId: environment.environmentId }))?.compose ?? []).filter((item) => item.name === context.name).at(-1)?.composeId;
954
+ if (!composeId) throw new Error("Dokploy created the compose service but did not return its id.");
955
+ await api.post("compose.update", {
956
+ composeId,
957
+ sourceType: "git",
958
+ customGitUrl: context.repository,
959
+ customGitBranch: context.branch,
960
+ composePath: "./compose.yaml",
961
+ env: envText(context.env)
962
+ });
963
+ ask.step(`Created the compose service ${context.name} from ${context.repository} (${context.branch})`);
964
+ if (!(await api.get("domain.byComposeId", { composeId }).catch(() => []) ?? []).some((item) => item.host === context.domain)) await api.post("domain.create", {
965
+ host: context.domain,
966
+ path: "/",
967
+ port: 80,
968
+ https: true,
969
+ certificateType: "letsencrypt",
970
+ composeId,
971
+ serviceName: "proxy",
972
+ domainType: "compose"
973
+ });
974
+ ask.step(`Gave the proxy service https://${context.domain}`);
975
+ return {
976
+ project: project.projectId,
977
+ environment: environment.environmentId,
978
+ application: composeId
979
+ };
980
+ }
981
+ async pushEnv(link, env) {
982
+ await this.api.post("compose.update", {
983
+ composeId: link.application,
984
+ env: envText(env)
985
+ });
986
+ }
987
+ async deploy(link) {
988
+ const before = new Set((await this.raw(link)).map((item) => item.deploymentId));
989
+ await this.api.post("compose.deploy", {
990
+ composeId: link.application,
991
+ title: "dstack deploy"
992
+ });
993
+ for (let attempt = 0; attempt < 20; attempt++) {
994
+ const fresh = (await this.raw(link)).find((item) => !before.has(item.deploymentId));
995
+ if (fresh) return fresh.deploymentId;
996
+ await new Promise((resolve) => setTimeout(resolve, this.pollMs));
997
+ }
998
+ throw new Error(`Dokploy took the deploy but shows no deployment for it yet. See ${composeUrl(this.api, link)}`);
999
+ }
1000
+ /** How long `deploy` waits between looks for the new deployment; tests set it to 0. */
1001
+ pollMs = 1e3;
1002
+ /** The deployments, newest first. */
1003
+ async raw(link) {
1004
+ return [...await this.api.get("deployment.allByCompose", { composeId: link.application }) ?? []].sort((a, b) => String(b.createdAt ?? "").localeCompare(String(a.createdAt ?? "")));
1005
+ }
1006
+ toDeployment(link, item) {
1007
+ const status = dokployDeploymentStatus(item.status);
1008
+ const url = composeUrl(this.api, link);
1009
+ return {
1010
+ id: item.deploymentId,
1011
+ status,
1012
+ message: item.title && item.title !== "dstack deploy" ? item.title : (item.description ?? void 0) || void 0,
1013
+ createdAt: item.createdAt,
1014
+ updatedAt: item.finishedAt ?? item.startedAt ?? void 0,
1015
+ url,
1016
+ log: [...item.errorMessage ? [{
1017
+ text: item.errorMessage,
1018
+ stderr: true
1019
+ }] : [], {
1020
+ text: `Dokploy shows the build log in its dashboard: ${url} (Deployments)`,
1021
+ stderr: false
1022
+ }]
1023
+ };
1024
+ }
1025
+ async deployment(link, id) {
1026
+ const item = (await this.raw(link)).find((deployment) => deployment.deploymentId === id);
1027
+ if (!item) throw new Error(`Dokploy has no deployment ${id} for this service.`);
1028
+ return this.toDeployment(link, item);
1029
+ }
1030
+ async deployments(link, limit) {
1031
+ return (await this.raw(link)).slice(0, limit).map((item) => ({
1032
+ ...this.toDeployment(link, item),
1033
+ log: []
1034
+ }));
1035
+ }
1036
+ async status(link) {
1037
+ const compose = await this.api.get("compose.one", { composeId: link.application });
1038
+ const domains = (compose.domains ?? []).map((item) => `${item.https === false ? "http" : "https"}://${item.host}`);
1039
+ return {
1040
+ state: dokployAppState(compose.composeStatus),
1041
+ detail: compose.composeStatus ?? "unknown",
1042
+ domains,
1043
+ branch: compose.customGitBranch ?? void 0,
1044
+ url: composeUrl(this.api, link)
1045
+ };
1046
+ }
1047
+ };
1048
+ const dokploy = {
1049
+ id: "dokploy",
1050
+ name: "Dokploy",
1051
+ auth: {
1052
+ kind: "token",
1053
+ env: "DSTACK_DOKPLOY_TOKEN",
1054
+ help: (url) => `Create one in Dokploy under Settings › Profile › API/CLI (${url}/dashboard/settings/profile), as a user who may create projects.`
1055
+ },
1056
+ source: "remote",
1057
+ target: urlTarget("Dokploy", "https://dokploy.example.com"),
1058
+ platformEnv: PLATFORM_ENV,
1059
+ setupFlags: SHARED_SETUP_FLAGS,
1060
+ connect: (url, token) => {
1061
+ if (!token) throw new Error("Dokploy needs an API key.");
1062
+ return new DokployClient(url, token);
1063
+ }
1064
+ };
1065
+ // src/deploy/credentials.ts
1066
+ /**
1067
+ * API keys of deploy providers, never in the project: the OS keychain
1068
+ * (macOS Keychain, Windows Credential Manager, the Secret Service on Linux),
1069
+ * or a file only the user can read where there is no keychain. An
1070
+ * environment variable such as DSTACK_COOLIFY_TOKEN overrides both, for CI.
1071
+ */
1072
+ const SERVICE = "dstack";
1073
+ function systemKeyring() {
1074
+ try {
1075
+ const { Entry } = createRequire(import.meta.url)("@napi-rs/keyring");
1076
+ return {
1077
+ get: (account) => new Entry(SERVICE, account).getPassword() ?? void 0,
1078
+ set: (account, token) => new Entry(SERVICE, account).setPassword(token),
1079
+ delete: (account) => void new Entry(SERVICE, account).deletePassword()
1080
+ };
1081
+ } catch {
1082
+ return null;
1083
+ }
1084
+ }
1085
+ /** The account a key is saved under: one per provider and instance. */
1086
+ function accountFor(provider, url) {
1087
+ return `${provider}:${url}`;
1088
+ }
1089
+ function credentialsFile() {
1090
+ const base = process.env.XDG_CONFIG_HOME || process.platform === "win32" && process.env.APPDATA || join(homedir(), ".config");
1091
+ return join(base, "dstack", "credentials.json");
1092
+ }
1093
+ function readFile() {
1094
+ const path = credentialsFile();
1095
+ if (!existsSync(path)) return {};
1096
+ try {
1097
+ return JSON.parse(readFileSync(path, "utf8"));
1098
+ } catch {
1099
+ return {};
1100
+ }
1101
+ }
1102
+ function writeFile(values) {
1103
+ const path = credentialsFile();
1104
+ mkdirSync(dirname(path), {
1105
+ recursive: true,
1106
+ mode: 448
1107
+ });
1108
+ writeFileSync(path, `${JSON.stringify(values, null, 2)}\n`, { mode: 384 });
1109
+ chmodSync(path, 384);
1110
+ }
1111
+ function getToken(provider, url, envName) {
1112
+ const fromEnv = process.env[envName];
1113
+ if (fromEnv) return {
1114
+ token: fromEnv,
1115
+ source: "env"
1116
+ };
1117
+ const account = accountFor(provider, url);
1118
+ const keyring = systemKeyring();
1119
+ if (keyring) try {
1120
+ const token = keyring.get(account);
1121
+ if (token) return {
1122
+ token,
1123
+ source: "keychain"
1124
+ };
1125
+ } catch {}
1126
+ const token = readFile()[account];
1127
+ return token ? {
1128
+ token,
1129
+ source: "file"
1130
+ } : void 0;
1131
+ }
1132
+ /** Saves a key in the keychain, or in the credentials file when there is none. */
1133
+ function saveToken(provider, url, token) {
1134
+ const account = accountFor(provider, url);
1135
+ const keyring = systemKeyring();
1136
+ if (keyring) try {
1137
+ keyring.set(account, token);
1138
+ removeFromFile(account);
1139
+ return "keychain";
1140
+ } catch {}
1141
+ writeFile({
1142
+ ...readFile(),
1143
+ [account]: token
1144
+ });
1145
+ return "file";
1146
+ }
1147
+ function removeFromFile(account) {
1148
+ const values = readFile();
1149
+ if (!(account in values)) return false;
1150
+ delete values[account];
1151
+ writeFile(values);
1152
+ return true;
1153
+ }
1154
+ /** Removes a key from the keychain and the file; returns whether there was one. */
1155
+ function deleteToken(provider, url) {
1156
+ const account = accountFor(provider, url);
1157
+ let removed = false;
1158
+ const keyring = systemKeyring();
1159
+ if (keyring) try {
1160
+ if (keyring.get(account)) {
1161
+ keyring.delete(account);
1162
+ removed = true;
1163
+ }
1164
+ } catch {}
1165
+ return removeFromFile(account) || removed;
1166
+ }
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
+ // src/deploy/index.ts
1278
+ const DEFAULT_PROVIDER = coolify;
1279
+ /** Every deploy provider. */
1280
+ const PROVIDERS = [
1281
+ coolify,
1282
+ dokploy,
1283
+ ssh
1284
+ ];
1285
+ function getProvider(id) {
1286
+ const provider = PROVIDERS.find((item) => item.id === id);
1287
+ if (!provider) throw new Error(`Unknown deploy provider ${id}. Choose one of: ${PROVIDERS.map((p) => p.id).join(", ")}.`);
1288
+ return provider;
1289
+ }
1290
+ // src/project.ts
1291
+ /** The `.env` of `dstack run` on a development machine: the prod profile at https://site.localhost. */
1292
+ const RUN_ENV = ".env.run";
1293
+ const RUN_DOMAIN = "site.localhost";
1294
+ /** The project directory: `start` or the nearest parent with `compose.yaml` and `.env`. */
1295
+ function findProjectDirectory(start) {
1296
+ let directory = resolve(start);
1297
+ for (;;) {
1298
+ if (existsSync(join(directory, "compose.yaml")) && existsSync(join(directory, ".env"))) return directory;
1299
+ const parent = dirname(directory);
1300
+ if (parent === directory) return void 0;
1301
+ directory = parent;
1302
+ }
1303
+ }
1304
+ function parseProfiles(value) {
1305
+ return (value ?? "").split(",").map((profile) => profile.trim()).filter(Boolean);
1306
+ }
1307
+ function projectName(directory) {
1308
+ try {
1309
+ const match = readFileSync(join(directory, "compose.yaml"), "utf8").match(/^name:\s*["']?([\w.-]+)/m);
1310
+ if (match?.[1]) return match[1];
1311
+ } catch {}
1312
+ return basename(directory).toLowerCase();
1313
+ }
1314
+ function fromEnv(directory, name, envFile, localRun) {
1315
+ const env = parseEnv(readFileSync(join(directory, envFile), "utf8"));
1316
+ const profiles = parseProfiles(env.COMPOSE_PROFILES);
1317
+ const prod = profiles.includes("prod");
1318
+ const suffix = prod ? "-prod" : "";
1319
+ return {
1320
+ directory,
1321
+ name: localRun ? `${name}-run` : name,
1322
+ env,
1323
+ profiles,
1324
+ prod,
1325
+ localRun,
1326
+ services: {
1327
+ backend: `backend${suffix}`,
1328
+ frontend: `frontend${suffix}`,
1329
+ worker: `worker${suffix}`
1330
+ },
1331
+ composeArgs: localRun ? [
1332
+ "--env-file",
1333
+ envFile,
1334
+ "--project-name",
1335
+ `${name}-run`
1336
+ ] : [],
1337
+ composeEnv: localRun ? {
1338
+ ENV_FILE: envFile,
1339
+ COMPOSE_PROFILES: env.COMPOSE_PROFILES ?? ""
1340
+ } : {}
1341
+ };
1342
+ }
1343
+ /** Reads the project in or above `start`. Throws with a one-line reason when there is none. */
1344
+ function readProject(start = ".") {
1345
+ const directory = findProjectDirectory(start);
1346
+ if (!directory) throw new Error(`No DjangoStack project in ${resolve(start)}: run this where compose.yaml and .env are.`);
1347
+ return fromEnv(directory, projectName(directory), ".env", false);
1348
+ }
1349
+ /** The local production run of a development project, when `.env.run` exists. */
1350
+ function readLocalRun(project) {
1351
+ if (project.prod || project.localRun || !existsSync(join(project.directory, ".env.run"))) return void 0;
1352
+ return fromEnv(project.directory, projectName(project.directory), RUN_ENV, true);
1353
+ }
1354
+ /** The `django-stack` section of the project's `package.json`. */
1355
+ function projectManifest(project) {
1356
+ try {
1357
+ return JSON.parse(readFileSync(join(project.directory, "package.json"), "utf8"))["django-stack"] ?? {};
1358
+ } catch {
1359
+ return {};
1360
+ }
1361
+ }
1362
+ /**
1363
+ * The project's stack: from the preset `init` saved in `package.json`, or,
1364
+ * for projects from before that, the defaults with the storage from `.env`.
1365
+ */
1366
+ function projectStack(project) {
1367
+ const { preset } = projectManifest(project);
1368
+ if (preset) try {
1369
+ return decodePreset(preset);
1370
+ } catch {}
1371
+ return {
1372
+ ...DEFAULT_STACK,
1373
+ storage: project.profiles.includes("s3") ? "s3" : "local"
1374
+ };
1375
+ }
1376
+ /** The URLs of a running project. */
1377
+ function projectUrls(project) {
1378
+ if (project.prod) {
1379
+ const site = (project.env.SITE_URL ?? "").replace(/\/$/, "");
1380
+ return [{
1381
+ label: "Site",
1382
+ url: `${site}/`
1383
+ }, {
1384
+ label: "Admin",
1385
+ url: `${site}/admin/`
1386
+ }];
1387
+ }
1388
+ const urls = [
1389
+ {
1390
+ label: "Site",
1391
+ url: "http://localhost:3000"
1392
+ },
1393
+ {
1394
+ label: "Admin",
1395
+ url: "http://localhost:3000/admin/"
1396
+ },
1397
+ {
1398
+ label: "Mail",
1399
+ url: "http://localhost:8025"
1400
+ }
1401
+ ];
1402
+ if (project.profiles.includes("s3")) urls.push({
1403
+ label: "S3",
1404
+ url: "http://localhost:8333"
1405
+ });
1406
+ return urls;
1407
+ }
1408
+ /** Runs a command with the terminal attached and returns its exit status. */
1409
+ const runInherit = (cmd, args, options) => {
1410
+ const result = spawnSync(cmd, args, {
1411
+ stdio: "inherit",
1412
+ ...options
1413
+ });
1414
+ if (result.error) return 127;
1415
+ return result.status ?? 1;
1416
+ };
1417
+ /** The full `docker compose` argument list for a project. */
1418
+ function composeCommand(project, args) {
1419
+ return [
1420
+ "compose",
1421
+ ...project.composeArgs,
1422
+ ...args
1423
+ ];
1424
+ }
1425
+ function composeOptions(project) {
1426
+ return {
1427
+ cwd: project.directory,
1428
+ env: {
1429
+ ...process.env,
1430
+ ...project.composeEnv
1431
+ }
1432
+ };
1433
+ }
1434
+ /** Runs `docker compose <args>` for the project with the terminal attached. */
1435
+ function compose(project, args, run = runInherit) {
1436
+ return run("docker", composeCommand(project, args), composeOptions(project));
1437
+ }
1438
+ /** Runs `docker compose <args>` and captures the output. */
1439
+ function composeOutput(project, args) {
1440
+ const result = spawnSync("docker", composeCommand(project, args), {
1441
+ ...composeOptions(project),
1442
+ encoding: "utf8",
1443
+ stdio: [
1444
+ "ignore",
1445
+ "pipe",
1446
+ "pipe"
1447
+ ]
1448
+ });
1449
+ return {
1450
+ status: result.status ?? 1,
1451
+ output: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim()
1452
+ };
1453
+ }
1454
+ /** The running services of the project. */
1455
+ function runningServices(project) {
1456
+ const result = spawnSync("docker", composeCommand(project, [
1457
+ "ps",
1458
+ "--services",
1459
+ "--filter",
1460
+ "status=running"
1461
+ ]), {
1462
+ ...composeOptions(project),
1463
+ encoding: "utf8",
1464
+ stdio: [
1465
+ "ignore",
1466
+ "pipe",
1467
+ "ignore"
1468
+ ]
1469
+ });
1470
+ return result.status === 0 ? result.stdout.split("\n").filter(Boolean) : [];
1471
+ }
1472
+ /**
1473
+ * The project a command acts on: the local production run when it is the
1474
+ * one running, otherwise the project `.env` describes.
1475
+ */
1476
+ function activeProject(project) {
1477
+ const localRun = readLocalRun(project);
1478
+ if (!localRun) return project;
1479
+ if (runningServices(project).length) return project;
1480
+ return runningServices(localRun).length ? localRun : project;
1481
+ }
11
1482
  // src/ui.ts
12
1483
  /** The DjangoStack badge with the version and an optional title. */
13
1484
  function intro(title) {
@@ -18,24 +1489,454 @@ function fail(message) {
18
1489
  p.cancel(pc.red(message));
19
1490
  process.exit(1);
20
1491
  }
21
- function unwrap(value) {
22
- if (p.isCancel(value)) fail("Cancelled.");
23
- return value;
1492
+ function unwrap(value) {
1493
+ if (p.isCancel(value)) fail("Cancelled.");
1494
+ return value;
1495
+ }
1496
+ function has(command, args = ["--version"]) {
1497
+ try {
1498
+ execFileSync(command, args, { stdio: "ignore" });
1499
+ return true;
1500
+ } catch {
1501
+ return false;
1502
+ }
1503
+ }
1504
+ const url = (value) => pc.cyan(pc.underline(value));
1505
+ const command = (value) => pc.cyan(value);
1506
+ const secret = (value) => pc.bold(pc.yellow(value));
1507
+ /** Prompts only when stdin is a terminal and `--yes` was not passed. */
1508
+ function isInteractive(yes) {
1509
+ return !yes && Boolean(process.stdin.isTTY);
1510
+ }
1511
+ /** Logs a check, with its help lines when it did not pass. */
1512
+ function logCheck(check) {
1513
+ const head = check.detail ? `${check.name} ${pc.dim(`- ${check.detail}`)}` : check.name;
1514
+ const help = !check.ok && check.help ? check.help.map((line) => pc.dim(line)) : [];
1515
+ const text = [check.ok ? head : check.warning ? pc.yellow(head) : pc.red(head), ...help].join("\n");
1516
+ if (check.ok) p.log.success(text);
1517
+ else if (check.warning) p.log.warn(text);
1518
+ else p.log.error(text);
1519
+ }
1520
+ // src/commands/deploy.ts
1521
+ const PRODUCTION_ENV = ".env.production";
1522
+ function projectOrFail(options) {
1523
+ try {
1524
+ return readProject(options.dir ?? ".");
1525
+ } catch (error) {
1526
+ fail(error.message);
1527
+ }
1528
+ }
1529
+ function linkOrFail(directory) {
1530
+ let link;
1531
+ try {
1532
+ link = readLink(directory);
1533
+ } catch (error) {
1534
+ fail(error.message);
1535
+ }
1536
+ if (!link) fail(`This project is not set up for deploys yet. Run ${command("dstack deploy setup")}.`);
1537
+ return link;
1538
+ }
1539
+ function providerOrFail(id) {
1540
+ try {
1541
+ return getProvider(id);
1542
+ } catch (error) {
1543
+ fail(error.message);
1544
+ }
1545
+ }
1546
+ /** Runs a provider call; a failure ends the command with its one-line reason. */
1547
+ async function attempt(work, spinner) {
1548
+ try {
1549
+ return await work;
1550
+ } catch (error) {
1551
+ spinner?.error("Failed");
1552
+ fail(error.message);
1553
+ }
1554
+ }
1555
+ /** The variables for the platform: .env.production plus what the platform needs. */
1556
+ function deployEnv(directory, provider) {
1557
+ let file = join(directory, PRODUCTION_ENV);
1558
+ if (!existsSync(file)) {
1559
+ if (!(parseEnv(readFileSync(join(directory, ".env"), "utf8")).COMPOSE_PROFILES ?? "").split(",").includes("prod")) fail(`${PRODUCTION_ENV} does not exist. Run ${command("dstack production --domain example.com")} first.`);
1560
+ file = join(directory, ".env");
1561
+ }
1562
+ return {
1563
+ ...parseEnv(readFileSync(file, "utf8")),
1564
+ ...provider.platformEnv
1565
+ };
1566
+ }
1567
+ /** The API key settings of a provider, or a one-line failure for one that uses SSH. */
1568
+ function tokenAuth(provider) {
1569
+ if (provider.auth.kind !== "token") fail(`${provider.name} uses your SSH keys and ~/.ssh/config; there is no API key to save.`);
1570
+ return provider.auth;
1571
+ }
1572
+ async function askToken(provider, instance) {
1573
+ p.log.info(tokenAuth(provider).help(instance));
1574
+ return unwrap(await p.password({
1575
+ message: `${provider.name} API key`,
1576
+ validate: (value) => value?.trim() ? void 0 : "Paste the API key."
1577
+ })).trim();
1578
+ }
1579
+ /** Checks a key and saves it; returns it. */
1580
+ async function login(provider, instance, token) {
1581
+ const spinner = p.spinner();
1582
+ spinner.start(`Checking the key with ${instance}`);
1583
+ const version = await attempt(provider.connect(instance, token).verify(), spinner);
1584
+ spinner.stop(`${provider.name} ${version} accepts the key`);
1585
+ if (saveToken(provider.id, instance, token) === "keychain") p.log.success("Saved the key in the system keychain.");
1586
+ else p.log.warn(`There is no system keychain here, so the key is in ~/.config/dstack/credentials.json, readable only by you. Set ${tokenAuth(provider).env} instead to keep it off disk.`);
1587
+ return token;
1588
+ }
1589
+ /** A client for a provider target: the saved key, or a new one when the CLI may ask. */
1590
+ async function connect(provider, instance, interactive) {
1591
+ if (provider.auth.kind === "ssh") return attempt(Promise.resolve().then(() => provider.connect(instance)));
1592
+ const saved = getToken(provider.id, instance, provider.auth.env);
1593
+ if (saved) return provider.connect(instance, saved.token);
1594
+ if (!interactive) fail(`No ${provider.name} API key for ${instance}. Run ${command("dstack deploy login")} or set ${provider.auth.env}.`);
1595
+ const token = await login(provider, instance, await askToken(provider, instance));
1596
+ return provider.connect(instance, token);
1597
+ }
1598
+ function promptAsk(interactive) {
1599
+ return {
1600
+ interactive,
1601
+ async select(message, choices, initial) {
1602
+ if (!interactive) {
1603
+ if (initial !== void 0) return initial;
1604
+ fail(`${message}: pass it as a flag; see ${command("dstack deploy setup --help")}.`);
1605
+ }
1606
+ return unwrap(await p.select({
1607
+ message,
1608
+ initialValue: initial,
1609
+ options: choices.map((choice) => ({
1610
+ value: choice.value,
1611
+ label: choice.label,
1612
+ hint: choice.hint
1613
+ }))
1614
+ }));
1615
+ },
1616
+ async text(message, options = {}) {
1617
+ if (!interactive) {
1618
+ if (options.initial) return options.initial;
1619
+ fail(`${message}: pass it as a flag.`);
1620
+ }
1621
+ return unwrap(await p.text({
1622
+ message,
1623
+ placeholder: options.placeholder,
1624
+ initialValue: options.initial
1625
+ }));
1626
+ },
1627
+ async confirm(message, initial = true) {
1628
+ if (!interactive) return initial;
1629
+ return unwrap(await p.confirm({
1630
+ message,
1631
+ initialValue: initial
1632
+ }));
1633
+ },
1634
+ step: (message) => p.log.success(message)
1635
+ };
1636
+ }
1637
+ /** `dstack deploy setup`: create the application on the platform and link the project to it. */
1638
+ async function deploySetup(options) {
1639
+ const interactive = isInteractive(options.yes);
1640
+ const project = projectOrFail(options);
1641
+ intro("Deploy setup");
1642
+ const existing = existsSync(join(project.directory, ".dstack/deploy.json")) ? readLink(project.directory) : void 0;
1643
+ if (existing && !options.force) fail(`This project already deploys to ${existing.url} (${LINK_FILE}). Pass --force to set it up again.`);
1644
+ let providerId = options.provider;
1645
+ if (!providerId) providerId = PROVIDERS.length === 1 || !interactive ? DEFAULT_PROVIDER.id : unwrap(await p.select({
1646
+ message: "Platform",
1647
+ options: PROVIDERS.map((item) => ({
1648
+ value: item.id,
1649
+ label: item.name
1650
+ }))
1651
+ }));
1652
+ const provider = providerOrFail(providerId);
1653
+ let instance = options.url;
1654
+ if (!instance) {
1655
+ if (!interactive) fail(`Pass --url, the address of your ${provider.name}.`);
1656
+ instance = unwrap(await p.text({
1657
+ message: provider.target.label,
1658
+ placeholder: provider.target.placeholder,
1659
+ validate: (value) => {
1660
+ try {
1661
+ provider.target.normalize(value ?? "");
1662
+ } catch (error) {
1663
+ return error.message;
1664
+ }
1665
+ }
1666
+ }));
1667
+ }
1668
+ try {
1669
+ instance = provider.target.normalize(instance);
1670
+ } catch (error) {
1671
+ fail(error.message);
1672
+ }
1673
+ const env = deployEnv(project.directory, provider);
1674
+ const domain = (env.SITE_URL ?? "").replace(/^https?:\/\//, "").replace(/\/.*$/, "");
1675
+ if (!domain || domain.endsWith("localhost")) fail(`SITE_URL in ${PRODUCTION_ENV} is not a public domain. Run ${command("dstack production --domain example.com --force")}.`);
1676
+ let repository = options.repository;
1677
+ if (provider.source === "remote") {
1678
+ if (!repository) {
1679
+ const remote = gitRemote(project.directory);
1680
+ if (!remote) fail("This project has no git remote origin. Push it to GitHub first, or pass --repository.");
1681
+ repository = remote;
1682
+ }
1683
+ try {
1684
+ repository = httpsRepository(repository);
1685
+ } catch (error) {
1686
+ fail(error.message);
1687
+ }
1688
+ }
1689
+ const branch = options.branch ?? gitBranch(project.directory) ?? "main";
1690
+ const client = await connect(provider, instance, interactive);
1691
+ await attempt(client.verify());
1692
+ const flags = {};
1693
+ for (const flag of provider.setupFlags) flags[flag.name] = options[flag.name];
1694
+ const ids = await attempt(client.setup({
1695
+ ask: promptAsk(interactive),
1696
+ name: project.name,
1697
+ repository,
1698
+ branch,
1699
+ domain,
1700
+ env,
1701
+ options: flags
1702
+ }));
1703
+ const link = {
1704
+ provider: provider.id,
1705
+ url: instance,
1706
+ ...ids
1707
+ };
1708
+ writeLink(project.directory, link);
1709
+ 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");
1711
+ if (options.deploy ?? (interactive ? unwrap(await p.confirm({
1712
+ message: "Deploy now?",
1713
+ initialValue: true
1714
+ })) : false)) {
1715
+ await startDeploy(project.directory, provider, link, client, {
1716
+ force: false,
1717
+ wait: true
1718
+ });
1719
+ return;
1720
+ }
1721
+ p.outro(`Deploy with ${command("dstack deploy")}`);
1722
+ }
1723
+ const STATUS_LABEL = {
1724
+ queued: pc.dim("queued"),
1725
+ building: pc.yellow("building"),
1726
+ finished: pc.green("finished"),
1727
+ failed: pc.red("failed"),
1728
+ cancelled: pc.dim("cancelled")
1729
+ };
1730
+ const DONE = /* @__PURE__ */ new Set([
1731
+ "finished",
1732
+ "failed",
1733
+ "cancelled"
1734
+ ]);
1735
+ const POLL_MS = 3e3;
1736
+ const WAIT_LIMIT_MS = 27e5;
1737
+ function printLog(deployment, lines) {
1738
+ const log = lines ? deployment.log.slice(-lines) : deployment.log;
1739
+ for (const line of log) process.stdout.write(`${line.stderr ? pc.red(line.text) : line.text}\n`);
1740
+ }
1741
+ async function follow(link, client, id) {
1742
+ const spinner = p.spinner();
1743
+ spinner.start("Queued");
1744
+ const started = Date.now();
1745
+ let deployment;
1746
+ for (;;) {
1747
+ deployment = await attempt(client.deployment(link, id), spinner);
1748
+ if (DONE.has(deployment.status)) break;
1749
+ const last = deployment.log.at(-1)?.text.trim().slice(0, 70);
1750
+ const seconds = Math.round((Date.now() - started) / 1e3);
1751
+ spinner.message(`${deployment.status === "queued" ? "Queued" : "Building"} ${pc.dim(`${seconds}s`)}${last ? pc.dim(` ${last}`) : ""}`);
1752
+ if (Date.now() - started > WAIT_LIMIT_MS) {
1753
+ spinner.stop("Still running");
1754
+ fail(`The deploy takes longer than 45 minutes. Follow it with ${command("dstack deploy status")}.`);
1755
+ }
1756
+ await new Promise((resolve) => setTimeout(resolve, POLL_MS));
1757
+ }
1758
+ const seconds = Math.round((Date.now() - started) / 1e3);
1759
+ const summary = `${STATUS_LABEL[deployment.status]} after ${seconds}s`;
1760
+ if (deployment.status === "finished") spinner.stop(summary);
1761
+ else spinner.error(summary);
1762
+ return deployment;
1763
+ }
1764
+ /** Why the deploy would not include the local work, or undefined. */
1765
+ async function notDeployedReason(directory, provider, link, client) {
1766
+ if (provider.source === "local") return uncommittedReason(directory);
1767
+ const status = await attempt(client.status(link));
1768
+ return status.branch ? unpushedReason(directory, status.branch) : void 0;
1769
+ }
1770
+ async function startDeploy(directory, provider, link, client, options) {
1771
+ if (options.pushCheck !== false) {
1772
+ const reason = await notDeployedReason(directory, provider, link, client);
1773
+ if (reason) fail(`${reason} Or pass --no-push-check.`);
1774
+ }
1775
+ await finishDeploy(link, client, await attempt(client.deploy(link, {
1776
+ force: options.force,
1777
+ directory
1778
+ })), options.wait);
1779
+ }
1780
+ /** Follows a started deploy, or reports where it runs when not waiting. */
1781
+ async function finishDeploy(link, client, id, wait) {
1782
+ if (!wait) {
1783
+ const deployment = await attempt(client.deployment(link, id));
1784
+ p.outro(`Started deploy ${pc.bold(id)}${deployment.url ? `: ${url(deployment.url)}` : ""}. Follow it with ${command("dstack deploy status --watch")}`);
1785
+ return;
1786
+ }
1787
+ p.log.message(pc.dim("Ctrl+C stops following; the deploy goes on."));
1788
+ const deployment = await follow(link, client, id);
1789
+ if (deployment.status !== "finished") {
1790
+ printLog(deployment, 40);
1791
+ fail(`The deploy ${deployment.status === "cancelled" ? "was cancelled" : "failed"}. Full log: ${command(`dstack deploy logs ${id}`)}`);
1792
+ }
1793
+ const status = await attempt(client.status(link));
1794
+ p.outro(`Deployed${status.domains.length ? `: ${status.domains.map(url).join(" ")}` : ""}`);
1795
+ }
1796
+ /** `dstack deploy`: start a deploy and follow it. */
1797
+ async function deploy(options) {
1798
+ const project = projectOrFail(options);
1799
+ const link = linkOrFail(project.directory);
1800
+ const provider = providerOrFail(link.provider);
1801
+ intro(`Deploy to ${provider.name}`);
1802
+ const client = await connect(provider, link.url, isInteractive(options.yes));
1803
+ await startDeploy(project.directory, provider, link, client, {
1804
+ force: Boolean(options.force),
1805
+ wait: options.wait,
1806
+ pushCheck: options.pushCheck
1807
+ });
1808
+ }
1809
+ function ago(date) {
1810
+ if (!date) return "";
1811
+ const seconds = Math.max(0, Math.round((Date.now() - new Date(date).getTime()) / 1e3));
1812
+ if (Number.isNaN(seconds)) return "";
1813
+ if (seconds < 60) return `${seconds}s ago`;
1814
+ if (seconds < 3600) return `${Math.round(seconds / 60)} min ago`;
1815
+ if (seconds < 86400) return `${Math.round(seconds / 3600)} h ago`;
1816
+ return `${Math.round(seconds / 86400)} d ago`;
1817
+ }
1818
+ const STATE_LABEL = {
1819
+ running: pc.green("running"),
1820
+ degraded: pc.yellow("degraded"),
1821
+ stopped: pc.red("stopped"),
1822
+ unknown: pc.dim("unknown")
1823
+ };
1824
+ /** `dstack deploy status`: the application's state and its last deploys. */
1825
+ async function deployStatus(options) {
1826
+ const project = projectOrFail(options);
1827
+ const link = linkOrFail(project.directory);
1828
+ const provider = providerOrFail(link.provider);
1829
+ intro(`${provider.name} status`);
1830
+ const client = await connect(provider, link.url, isInteractive(false));
1831
+ const limit = Math.max(1, Number.parseInt(options.limit, 10) || 5);
1832
+ const [status, deployments] = await Promise.all([attempt(client.status(link)), attempt(client.deployments(link, limit))]);
1833
+ p.note([
1834
+ `${pc.bold("State")} ${STATE_LABEL[status.state]} ${pc.dim(`(${status.detail})`)}`,
1835
+ ...status.domains.length ? [`${pc.bold("Site")} ${status.domains.map(url).join(" ")}`] : [],
1836
+ ...status.branch ? [`${pc.bold("Branch")} ${status.branch}`] : [],
1837
+ ...status.url ? [`${pc.bold(provider.name.padEnd(7))} ${url(status.url)}`] : []
1838
+ ].join("\n"), project.name);
1839
+ if (deployments.length) p.log.message(deployments.map((item) => [
1840
+ STATUS_LABEL[item.status].padEnd(18),
1841
+ pc.dim(ago(item.createdAt).padEnd(11)),
1842
+ item.commit ? pc.cyan(item.commit) : pc.dim("-------"),
1843
+ item.message ?? ""
1844
+ ].join(" ")).join("\n"), { symbol: pc.dim("│") });
1845
+ else p.log.message(pc.dim("No deploys yet."));
1846
+ const running = deployments.find((item) => !DONE.has(item.status));
1847
+ if (running && options.watch) {
1848
+ const deployment = await follow(link, client, running.id);
1849
+ if (deployment.status !== "finished") {
1850
+ printLog(deployment, 40);
1851
+ fail(`The deploy failed. Full log: ${command(`dstack deploy logs ${running.id}`)}`);
1852
+ }
1853
+ } else if (running) {
1854
+ p.outro(`A deploy is ${running.status}. Follow it with ${command("dstack deploy status --watch")}`);
1855
+ return;
1856
+ }
1857
+ p.outro(pc.green("Done"));
1858
+ }
1859
+ /** `dstack deploy logs [deployment]`: the build log of a deploy, the latest by default. */
1860
+ async function deployLogs(id, options) {
1861
+ const link = linkOrFail(projectOrFail(options).directory);
1862
+ const client = await connect(providerOrFail(link.provider), link.url, isInteractive(false));
1863
+ let deploymentId = id;
1864
+ if (!deploymentId) {
1865
+ const [latest] = await attempt(client.deployments(link, 1));
1866
+ if (!latest) fail("There are no deploys yet.");
1867
+ deploymentId = latest.id;
1868
+ }
1869
+ const deployment = await attempt(client.deployment(link, deploymentId));
1870
+ printLog(deployment);
1871
+ process.stderr.write(pc.dim(`Deploy ${deployment.id}: ${deployment.status}\n`));
1872
+ if (deployment.status === "failed") process.exitCode = 1;
1873
+ }
1874
+ /** `dstack deploy rollback [deployment]`: run an earlier release again. */
1875
+ async function deployRollback(id, options) {
1876
+ const link = linkOrFail(projectOrFail(options).directory);
1877
+ const provider = providerOrFail(link.provider);
1878
+ intro(`Roll back on ${provider.name}`);
1879
+ const client = await connect(provider, link.url, isInteractive(options.yes));
1880
+ if (!client.rollback) fail(`${provider.name} keeps no releases to roll back to. Deploy an earlier commit, or roll back in ${provider.name}.`);
1881
+ const rollbackId = await attempt(client.rollback(link, id));
1882
+ p.log.info(pc.dim("The database is not rolled back; migrations of the newer release stay applied."));
1883
+ await finishDeploy(link, client, rollbackId, options.wait);
1884
+ }
1885
+ /** `dstack deploy env push`: upload .env.production to the platform again. */
1886
+ async function deployEnvPush(options) {
1887
+ const project = projectOrFail(options);
1888
+ const link = linkOrFail(project.directory);
1889
+ const provider = providerOrFail(link.provider);
1890
+ intro("Push variables");
1891
+ const interactive = isInteractive(options.yes);
1892
+ const env = deployEnv(project.directory, provider);
1893
+ if (interactive && !unwrap(await p.confirm({ message: `Replace ${Object.keys(env).length} variables in ${provider.name} with the values from ${PRODUCTION_ENV}?` }))) fail("Cancelled.");
1894
+ await attempt((await connect(provider, link.url, interactive)).pushEnv(link, env));
1895
+ p.outro(`Set ${Object.keys(env).length} variables. They apply on the next ${command("dstack deploy")}`);
24
1896
  }
25
- function has(command, args = ["--version"]) {
1897
+ async function loginTarget(options, interactive) {
1898
+ let link;
26
1899
  try {
27
- execFileSync(command, args, { stdio: "ignore" });
28
- return true;
29
- } catch {
30
- return false;
1900
+ link = readLink(readProject(options.dir ?? ".").directory);
1901
+ } catch {}
1902
+ const provider = providerOrFail(options.provider ?? link?.provider ?? DEFAULT_PROVIDER.id);
1903
+ tokenAuth(provider);
1904
+ let instance = options.url ?? link?.url;
1905
+ if (!instance) {
1906
+ if (!interactive) fail(`Pass --url, the address of your ${provider.name}.`);
1907
+ instance = unwrap(await p.text({
1908
+ message: provider.target.label,
1909
+ placeholder: provider.target.placeholder
1910
+ }));
1911
+ }
1912
+ try {
1913
+ return {
1914
+ provider,
1915
+ instance: provider.target.normalize(instance)
1916
+ };
1917
+ } catch (error) {
1918
+ fail(error.message);
31
1919
  }
32
1920
  }
33
- const url = (value) => pc.cyan(pc.underline(value));
34
- const command = (value) => pc.cyan(value);
35
- const secret = (value) => pc.bold(pc.yellow(value));
36
- /** Prompts only when stdin is a terminal and `--yes` was not passed. */
37
- function isInteractive(yes) {
38
- return !yes && Boolean(process.stdin.isTTY);
1921
+ /** `dstack deploy login`: check and save an API key. */
1922
+ async function deployLogin(options) {
1923
+ const interactive = isInteractive(options.yes);
1924
+ intro("Deploy login");
1925
+ const { provider, instance } = await loginTarget(options, interactive);
1926
+ const auth = tokenAuth(provider);
1927
+ let token = options.token ?? (options.yes ? process.env[auth.env] : void 0);
1928
+ if (!token) {
1929
+ if (!interactive) fail(`Pass --token or set ${auth.env}.`);
1930
+ token = await askToken(provider, instance);
1931
+ }
1932
+ await login(provider, instance, token);
1933
+ p.outro(pc.green("Done"));
1934
+ }
1935
+ /** `dstack deploy logout`: remove a saved API key. */
1936
+ async function deployLogout(options) {
1937
+ const { provider, instance } = await loginTarget(options, isInteractive(false));
1938
+ if (deleteToken(provider.id, instance)) p.log.success(`Removed the ${provider.name} key for ${instance}.`);
1939
+ else p.log.info(`There was no saved ${provider.name} key for ${instance}.`);
39
1940
  }
40
1941
  // src/commands/doctor.ts
41
1942
  async function doctor(directoryArgument) {
@@ -45,12 +1946,7 @@ async function doctor(directoryArgument) {
45
1946
  spinner.start("Checking the project and its services");
46
1947
  const checks = await runDoctor(directory);
47
1948
  spinner.clear();
48
- for (const check of checks) {
49
- const line = check.detail ? `${check.name} ${pc.dim(`- ${check.detail}`)}` : check.name;
50
- if (check.ok) p.log.success(line);
51
- else if (check.warning) p.log.warn(pc.yellow(line));
52
- else p.log.error(pc.red(line));
53
- }
1949
+ for (const check of checks) logCheck(check);
54
1950
  const failed = checks.filter((check) => !check.ok && !check.warning).length;
55
1951
  if (failed) {
56
1952
  p.outro(pc.red(`${failed} ${failed === 1 ? "check" : "checks"} failed`));
@@ -141,7 +2037,7 @@ function updateCommand(location = import.meta.url, env = process.env) {
141
2037
  if (path.includes("/pnpm/global/")) return `pnpm add -g ${PACKAGE_NAME}@latest`;
142
2038
  if (path.includes("/.bun/install/global/")) return `bun add -g ${PACKAGE_NAME}@latest`;
143
2039
  if (env.npm_config_user_agent?.startsWith("pnpm")) return `pnpm add -g ${PACKAGE_NAME}@latest`;
144
- return `npm install -g ${PACKAGE_NAME}@latest`;
2040
+ return `npm i -g ${PACKAGE_NAME}@latest`;
145
2041
  }
146
2042
  // src/global-install.ts
147
2043
  /** The name of the installed command. */
@@ -187,8 +2083,8 @@ function installCommand(runner = "npm") {
187
2083
  ];
188
2084
  return [
189
2085
  "npm",
190
- "install",
191
- "--global",
2086
+ "i",
2087
+ "-g",
192
2088
  `${PACKAGE_NAME}@latest`
193
2089
  ];
194
2090
  }
@@ -363,9 +2259,24 @@ function systemTimeZone() {
363
2259
  return "UTC";
364
2260
  }
365
2261
  }
2262
+ /**
2263
+ * Checks Docker, Node.js and Python before the prompts. Problems only warn:
2264
+ * the project is created anyway, and Node.js and Python are for development
2265
+ * outside the containers. Returns whether Docker can run the project.
2266
+ */
2267
+ function checkComputer(checks = checkTools()) {
2268
+ const passed = checks.filter((check) => check.ok);
2269
+ if (passed.length) p.log.success(passed.map((check) => check.detail ?? check.name).join(", "));
2270
+ for (const check of checks.filter((check) => !check.ok)) logCheck({
2271
+ ...check,
2272
+ warning: true
2273
+ });
2274
+ return !checks.some((check) => !check.ok && !check.warning);
2275
+ }
366
2276
  async function init(directoryArgument, options) {
367
2277
  const interactive = isInteractive(options.yes);
368
2278
  intro("Create a project");
2279
+ const dockerReady = checkComputer();
369
2280
  const zoneError = options.timeZone ? validateTimeZone(options.timeZone) : void 0;
370
2281
  if (zoneError) fail(zoneError);
371
2282
  let directory = directoryArgument;
@@ -475,7 +2386,6 @@ async function init(directoryArgument, options) {
475
2386
  } catch {
476
2387
  p.log.warn("Could not commit the project with git. Commit it yourself.");
477
2388
  }
478
- if (!has("docker", ["compose", "version"])) p.log.warn(`Docker Compose was not found. Install Docker to run the project: ${url("https://docs.docker.com/get-docker/")}`);
479
2389
  p.note([
480
2390
  command(`cd ${relativeDirectory(target)}`),
481
2391
  command("docker compose up"),
@@ -489,7 +2399,8 @@ async function init(directoryArgument, options) {
489
2399
  ].join("\n"), "Next steps");
490
2400
  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(", ")}.`);
491
2401
  p.log.message(`Create the same stack with ${command(`--preset ${encodePreset(stack)}`)}`);
492
- if (options.start) {
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")}.`));
2403
+ else if (options.start) {
493
2404
  if (options.installPrompt !== false && !options.yes) await offerGlobalInstall();
494
2405
  p.outro(`Starting the project with ${command("docker compose up")}`);
495
2406
  execFileSync("docker", ["compose", "up"], {
@@ -605,198 +2516,6 @@ function setManifestPreset(manifestText, preset) {
605
2516
  };
606
2517
  return `${JSON.stringify(manifest, null, 2)}\n`;
607
2518
  }
608
- // src/project.ts
609
- /** The `.env` of `dstack run` on a development machine: the prod profile at https://site.localhost. */
610
- const RUN_ENV = ".env.run";
611
- const RUN_DOMAIN = "site.localhost";
612
- /** The project directory: `start` or the nearest parent with `compose.yaml` and `.env`. */
613
- function findProjectDirectory(start) {
614
- let directory = resolve(start);
615
- for (;;) {
616
- if (existsSync(join(directory, "compose.yaml")) && existsSync(join(directory, ".env"))) return directory;
617
- const parent = dirname(directory);
618
- if (parent === directory) return void 0;
619
- directory = parent;
620
- }
621
- }
622
- function parseProfiles(value) {
623
- return (value ?? "").split(",").map((profile) => profile.trim()).filter(Boolean);
624
- }
625
- function projectName(directory) {
626
- try {
627
- const match = readFileSync(join(directory, "compose.yaml"), "utf8").match(/^name:\s*["']?([\w.-]+)/m);
628
- if (match?.[1]) return match[1];
629
- } catch {}
630
- return basename(directory).toLowerCase();
631
- }
632
- function fromEnv(directory, name, envFile, localRun) {
633
- const env = parseEnv(readFileSync(join(directory, envFile), "utf8"));
634
- const profiles = parseProfiles(env.COMPOSE_PROFILES);
635
- const prod = profiles.includes("prod");
636
- const suffix = prod ? "-prod" : "";
637
- return {
638
- directory,
639
- name: localRun ? `${name}-run` : name,
640
- env,
641
- profiles,
642
- prod,
643
- localRun,
644
- services: {
645
- backend: `backend${suffix}`,
646
- frontend: `frontend${suffix}`,
647
- worker: `worker${suffix}`
648
- },
649
- composeArgs: localRun ? [
650
- "--env-file",
651
- envFile,
652
- "--project-name",
653
- `${name}-run`
654
- ] : [],
655
- composeEnv: localRun ? {
656
- ENV_FILE: envFile,
657
- COMPOSE_PROFILES: env.COMPOSE_PROFILES ?? ""
658
- } : {}
659
- };
660
- }
661
- /** Reads the project in or above `start`. Throws with a one-line reason when there is none. */
662
- function readProject(start = ".") {
663
- const directory = findProjectDirectory(start);
664
- if (!directory) throw new Error(`No DjangoStack project in ${resolve(start)}: run this where compose.yaml and .env are.`);
665
- return fromEnv(directory, projectName(directory), ".env", false);
666
- }
667
- /** The local production run of a development project, when `.env.run` exists. */
668
- function readLocalRun(project) {
669
- if (project.prod || project.localRun || !existsSync(join(project.directory, ".env.run"))) return void 0;
670
- return fromEnv(project.directory, projectName(project.directory), RUN_ENV, true);
671
- }
672
- /** The `django-stack` section of the project's `package.json`. */
673
- function projectManifest(project) {
674
- try {
675
- return JSON.parse(readFileSync(join(project.directory, "package.json"), "utf8"))["django-stack"] ?? {};
676
- } catch {
677
- return {};
678
- }
679
- }
680
- /**
681
- * The project's stack: from the preset `init` saved in `package.json`, or,
682
- * for projects from before that, the defaults with the storage from `.env`.
683
- */
684
- function projectStack(project) {
685
- const { preset } = projectManifest(project);
686
- if (preset) try {
687
- return decodePreset(preset);
688
- } catch {}
689
- return {
690
- ...DEFAULT_STACK,
691
- storage: project.profiles.includes("s3") ? "s3" : "local"
692
- };
693
- }
694
- /** The URLs of a running project. */
695
- function projectUrls(project) {
696
- if (project.prod) {
697
- const site = (project.env.SITE_URL ?? "").replace(/\/$/, "");
698
- return [{
699
- label: "Site",
700
- url: `${site}/`
701
- }, {
702
- label: "Admin",
703
- url: `${site}/admin/`
704
- }];
705
- }
706
- const urls = [
707
- {
708
- label: "Site",
709
- url: "http://localhost:3000"
710
- },
711
- {
712
- label: "Admin",
713
- url: "http://localhost:3000/admin/"
714
- },
715
- {
716
- label: "Mail",
717
- url: "http://localhost:8025"
718
- }
719
- ];
720
- if (project.profiles.includes("s3")) urls.push({
721
- label: "S3",
722
- url: "http://localhost:8333"
723
- });
724
- return urls;
725
- }
726
- /** Runs a command with the terminal attached and returns its exit status. */
727
- const runInherit = (cmd, args, options) => {
728
- const result = spawnSync(cmd, args, {
729
- stdio: "inherit",
730
- ...options
731
- });
732
- if (result.error) return 127;
733
- return result.status ?? 1;
734
- };
735
- /** The full `docker compose` argument list for a project. */
736
- function composeCommand(project, args) {
737
- return [
738
- "compose",
739
- ...project.composeArgs,
740
- ...args
741
- ];
742
- }
743
- function composeOptions(project) {
744
- return {
745
- cwd: project.directory,
746
- env: {
747
- ...process.env,
748
- ...project.composeEnv
749
- }
750
- };
751
- }
752
- /** Runs `docker compose <args>` for the project with the terminal attached. */
753
- function compose(project, args, run = runInherit) {
754
- return run("docker", composeCommand(project, args), composeOptions(project));
755
- }
756
- /** Runs `docker compose <args>` and captures the output. */
757
- function composeOutput(project, args) {
758
- const result = spawnSync("docker", composeCommand(project, args), {
759
- ...composeOptions(project),
760
- encoding: "utf8",
761
- stdio: [
762
- "ignore",
763
- "pipe",
764
- "pipe"
765
- ]
766
- });
767
- return {
768
- status: result.status ?? 1,
769
- output: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim()
770
- };
771
- }
772
- /** The running services of the project. */
773
- function runningServices(project) {
774
- const result = spawnSync("docker", composeCommand(project, [
775
- "ps",
776
- "--services",
777
- "--filter",
778
- "status=running"
779
- ]), {
780
- ...composeOptions(project),
781
- encoding: "utf8",
782
- stdio: [
783
- "ignore",
784
- "pipe",
785
- "ignore"
786
- ]
787
- });
788
- return result.status === 0 ? result.stdout.split("\n").filter(Boolean) : [];
789
- }
790
- /**
791
- * The project a command acts on: the local production run when it is the
792
- * one running, otherwise the project `.env` describes.
793
- */
794
- function activeProject(project) {
795
- const localRun = readLocalRun(project);
796
- if (!localRun) return project;
797
- if (runningServices(project).length) return project;
798
- return runningServices(localRun).length ? localRun : project;
799
- }
800
2519
  // src/commands/project.ts
801
2520
  function open(options) {
802
2521
  if (!has("docker", ["compose", "version"])) fail("Docker Compose v2 was not found. Install Docker: https://docs.docker.com/get-docker/");
@@ -901,42 +2620,66 @@ async function manage(args, options) {
901
2620
  async function shell(options) {
902
2621
  return manage(["shell"], options);
903
2622
  }
904
- /** The project that runs the backups: the prod profile, on a server or the local run. */
2623
+ /**
2624
+ * The project and the service that run the backups: the prod worker, on a
2625
+ * server or the local run, or the `backup` service of projects from 0.1.
2626
+ */
905
2627
  function backupProject(options) {
906
2628
  const project = activeProject(open(options));
907
2629
  if (!project.prod) fail(`Backups run in the prod profile: on the server, or locally after ${command("dstack run")}.`);
908
- if (!runningServices(project).includes("backup")) fail(`The backup service is not running. Start the project with ${command("dstack run")}.`);
909
- return project;
2630
+ const running = runningServices(project);
2631
+ 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 {
2634
+ project,
2635
+ service
2636
+ };
910
2637
  }
911
- /** `dstack backup`: back up the database now, or list the backups. */
2638
+ /** `dstack backup`: back up the database and the media now, or list the backups. */
912
2639
  async function backup(options) {
913
- exitWith(compose(backupProject(options), [
2640
+ const { project, service } = backupProject(options);
2641
+ const args = [
2642
+ "python",
2643
+ "manage.py",
2644
+ "backup_database"
2645
+ ];
2646
+ if (options.list) args.push("--list");
2647
+ else if (!options.media) args.push("--no-media");
2648
+ exitWith(compose(project, [
914
2649
  "exec",
915
2650
  "-T",
916
- "backup",
917
- ...[
918
- "python",
919
- "manage.py",
920
- "backup_database",
921
- ...options.list ? ["--list"] : []
922
- ]
2651
+ service,
2652
+ ...args
923
2653
  ]));
924
2654
  }
925
- /** `dstack restore <backup>`: restore a backup over the database. */
926
- async function restore(name, options) {
927
- const project = backupProject(options);
928
- const args = [
2655
+ /** The management command and its options for `dstack restore`. */
2656
+ function restoreArgs(name, options) {
2657
+ const yes = options.yes ? ["--yes"] : [];
2658
+ if (name.endsWith(".media.tar.gz")) return [
2659
+ "python",
2660
+ "manage.py",
2661
+ "restore_media",
2662
+ name,
2663
+ ...yes
2664
+ ];
2665
+ return [
929
2666
  "python",
930
2667
  "manage.py",
931
2668
  "restore_database",
932
2669
  name,
933
- ...options.yes ? ["--yes"] : []
2670
+ ...options.media ? ["--media"] : [],
2671
+ ...yes
934
2672
  ];
2673
+ }
2674
+ /** `dstack restore <backup>`: restore a database dump, with --media its media too, or a media archive. */
2675
+ async function restore(name, options) {
2676
+ const { project, service } = backupProject(options);
2677
+ const args = restoreArgs(name, options);
935
2678
  if (!options.yes && !tty()) fail("Pass --yes to restore without a terminal.");
936
2679
  exitWith(compose(project, [
937
2680
  "exec",
938
2681
  ...options.yes ? ["-T"] : [],
939
- "backup",
2682
+ service,
940
2683
  ...args
941
2684
  ]));
942
2685
  }
@@ -1056,13 +2799,8 @@ async function check(options) {
1056
2799
  const doctorChecks = await runDoctor(project.directory);
1057
2800
  spinner.clear();
1058
2801
  for (const item of doctorChecks) {
1059
- const line = item.detail ? `${item.name} ${pc.dim(`- ${item.detail}`)}` : item.name;
1060
- if (item.ok) p.log.success(line);
1061
- else if (item.warning) p.log.warn(pc.yellow(line));
1062
- else {
1063
- p.log.error(pc.red(line));
1064
- failed++;
1065
- }
2802
+ logCheck(item);
2803
+ if (!item.ok && !item.warning) failed++;
1066
2804
  }
1067
2805
  }
1068
2806
  const running = runningServices(project);
@@ -1259,9 +2997,26 @@ function createProgram() {
1259
2997
  for (const flag of stackFlags()) configCommand.addOption(flag);
1260
2998
  program.command("manage").description("Run a Django management command in the backend").argument("<args...>", "The command and its options, such as createsuperuser").addOption(dirOption()).allowUnknownOption().helpOption(false).action((args, options) => manage(args, options));
1261
2999
  program.command("shell").description("Open Django's shell in the backend").addOption(dirOption()).action(shell);
1262
- program.command("backup").description("Back up the database now (prod profile)").addOption(dirOption()).option("--list", "List the backups instead").action(backup);
1263
- program.command("restore").description("Restore a backup over the database (prod profile)").argument("<backup>", "A file name from `dstack backup --list`").addOption(dirOption()).option("-y, --yes", "Don't ask for confirmation").action(restore);
3000
+ program.command("backup").description("Back up the database and the media now (prod profile)").addOption(dirOption()).option("--list", "List the backups instead").option("--no-media", "Back up only the database").action(backup);
3001
+ 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);
1264
3002
  program.command("types").description("Generate the frontend's API types from the backend's schema").addOption(dirOption()).action(types);
3003
+ 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");
3005
+ 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
+ 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
+ const seen = /* @__PURE__ */ new Set();
3008
+ for (const flag of PROVIDERS.flatMap((provider) => provider.setupFlags)) {
3009
+ if (seen.has(flag.name)) continue;
3010
+ seen.add(flag.name);
3011
+ const kebab = flag.name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
3012
+ setupCommand.option(`--${kebab} <value>`, flag.description);
3013
+ }
3014
+ deployCommand.command("status").description("Show the state of the site and the last deploys").addOption(dirOption()).option("-n, --limit <count>", "How many deploys to list", "5").option("--watch", "Follow a running deploy until it ends").action(deployStatus);
3015
+ deployCommand.command("logs").description("Print the build log of a deploy").argument("[deployment]", "A deploy id from `dstack deploy status` (default: the latest)").addOption(dirOption()).action(deployLogs);
3016
+ deployCommand.command("rollback").description("Run an earlier release again (servers over SSH)").argument("[deployment]", "A deploy id from `dstack deploy status` (default: the one before the running one)").addOption(dirOption()).option("--no-wait", "Don't follow the rollback; return once it started").option("-y, --yes", "Don't ask").action(deployRollback);
3017
+ deployCommand.command("env").description("Manage the platform's environment variables").command("push").description("Replace the platform's variables with .env.production").addOption(dirOption()).option("-y, --yes", "Don't ask for confirmation").action(deployEnvPush);
3018
+ deployCommand.command("login").description("Check and save an API key in the system keychain").addOption(dirOption()).addOption(providerOption()).option("--url <url>", "The platform's address (default: the one in .dstack/deploy.json)").option("--token <token>", "The API key (default: ask, or the provider's environment variable with --yes)").option("-y, --yes", "Don't ask").action(deployLogin);
3019
+ deployCommand.command("logout").description("Remove a saved API key").addOption(dirOption()).addOption(providerOption()).option("--url <url>", "The platform's address (default: the one in .dstack/deploy.json)").action(deployLogout);
1265
3020
  const presetCommand = program.command("preset").description("Print the preset code of a stack, or the stack of a code").argument("[preset]", "A preset code or name to show").option("-y, --yes", "Don't ask; use the flags and the defaults").action((code, options, cmd) => preset(code, {
1266
3021
  ...options,
1267
3022
  fromCli: (option) => cmd.getOptionValueSource(option) === "cli"