@django-stack/cli 0.2.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/README.md
CHANGED
|
@@ -19,7 +19,7 @@ docker compose up
|
|
|
19
19
|
|
|
20
20
|
The command is `dstack`. The first time you run the CLI with `npx`, it
|
|
21
21
|
offers to install `dstack` globally; or run
|
|
22
|
-
`npm
|
|
22
|
+
`npm i -g @django-stack/cli` yourself. Turn the offer off with
|
|
23
23
|
`--no-install-prompt` or `DJANGO_STACK_NO_INSTALL_PROMPT=1`.
|
|
24
24
|
|
|
25
25
|
Run `dstack help <command>` for the options of a command. Each command
|
package/dist/bin.js
CHANGED
|
@@ -9,6 +9,27 @@ import tab from "@bomb.sh/tab/commander";
|
|
|
9
9
|
import * as p from "@clack/prompts";
|
|
10
10
|
import { Command, InvalidArgumentError, Option } from "commander";
|
|
11
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
|
+
}
|
|
12
33
|
// src/deploy/coolify.ts
|
|
13
34
|
/**
|
|
14
35
|
* Coolify (https://coolify.io), through its API at /api/v1 with a bearer
|
|
@@ -24,6 +45,21 @@ const PLATFORM_ENV = {
|
|
|
24
45
|
HTTP_PORT: "127.0.0.1:8080",
|
|
25
46
|
HTTPS_PORT: "127.0.0.1:8443"
|
|
26
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
|
+
];
|
|
27
63
|
var CoolifyError = class extends Error {
|
|
28
64
|
status;
|
|
29
65
|
constructor(message, status) {
|
|
@@ -57,7 +93,7 @@ var CoolifyApi = class {
|
|
|
57
93
|
throw new CoolifyError(`Could not reach Coolify at ${this.url}: ${error.message}`, 0);
|
|
58
94
|
}
|
|
59
95
|
const text = await response.text();
|
|
60
|
-
if (!response.ok) throw new CoolifyError(errorMessage(response.status, text, this.url), response.status);
|
|
96
|
+
if (!response.ok) throw new CoolifyError(errorMessage$1(response.status, text, this.url), response.status);
|
|
61
97
|
if (!text) return void 0;
|
|
62
98
|
try {
|
|
63
99
|
return JSON.parse(text);
|
|
@@ -69,7 +105,7 @@ var CoolifyApi = class {
|
|
|
69
105
|
post = (path, body) => this.request("POST", path, body ?? {});
|
|
70
106
|
patch = (path, body) => this.request("PATCH", path, body);
|
|
71
107
|
};
|
|
72
|
-
function errorMessage(status, text, url) {
|
|
108
|
+
function errorMessage$1(status, text, url) {
|
|
73
109
|
if (status === 401) return `Coolify at ${url} rejected the API key. Run dstack deploy login to enter a new one.`;
|
|
74
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.";
|
|
75
111
|
if (status === 404 && text.includes("<html")) return `${url} does not answer like Coolify; check the URL.`;
|
|
@@ -131,7 +167,7 @@ function composeDomains(app) {
|
|
|
131
167
|
function appUrl(api, link) {
|
|
132
168
|
return `${api.url}/project/${link.project}/environment/${link.environment}/application/${link.application}`;
|
|
133
169
|
}
|
|
134
|
-
function toDeployment(api, link, item) {
|
|
170
|
+
function toDeployment$1(api, link, item) {
|
|
135
171
|
const path = item.deployment_url;
|
|
136
172
|
return {
|
|
137
173
|
id: item.deployment_uuid,
|
|
@@ -145,7 +181,7 @@ function toDeployment(api, link, item) {
|
|
|
145
181
|
};
|
|
146
182
|
}
|
|
147
183
|
/** Finds a name or uuid in a list. */
|
|
148
|
-
function find(items, wanted) {
|
|
184
|
+
function find$1(items, wanted) {
|
|
149
185
|
return items.find((item) => item.uuid === wanted) ?? items.find((item) => item.name === wanted);
|
|
150
186
|
}
|
|
151
187
|
var CoolifyClient = class {
|
|
@@ -160,15 +196,16 @@ var CoolifyClient = class {
|
|
|
160
196
|
}
|
|
161
197
|
async setup(context) {
|
|
162
198
|
const { ask, options } = context;
|
|
199
|
+
if (!context.repository) throw new Error("Coolify builds from a git repository; pass --repository.");
|
|
163
200
|
const api = this.api;
|
|
164
201
|
const servers = await api.get("/servers");
|
|
165
202
|
if (!servers.length) throw new Error("This Coolify has no servers. Add one in Coolify first.");
|
|
166
203
|
let server;
|
|
167
204
|
if (options.server) {
|
|
168
|
-
server = find(servers, options.server);
|
|
205
|
+
server = find$1(servers, options.server);
|
|
169
206
|
if (!server) throw new Error(`Coolify has no server ${options.server}.`);
|
|
170
207
|
} else if (servers.length === 1) server = servers[0];
|
|
171
|
-
else server = find(servers, await ask.select("Server to deploy to", servers.map((item) => ({
|
|
208
|
+
else server = find$1(servers, await ask.select("Server to deploy to", servers.map((item) => ({
|
|
172
209
|
value: item.uuid,
|
|
173
210
|
label: item.name,
|
|
174
211
|
hint: item.ip
|
|
@@ -177,7 +214,7 @@ var CoolifyClient = class {
|
|
|
177
214
|
const projects = await api.get("/projects");
|
|
178
215
|
let project;
|
|
179
216
|
const projectName = options.project ?? context.name;
|
|
180
|
-
project = find(projects, projectName);
|
|
217
|
+
project = find$1(projects, projectName);
|
|
181
218
|
if (!options.project && ask.interactive && projects.length) {
|
|
182
219
|
const NEW = "";
|
|
183
220
|
const uuid = await ask.select("Coolify project", [...project ? [] : [{
|
|
@@ -188,7 +225,7 @@ var CoolifyClient = class {
|
|
|
188
225
|
label: item.name,
|
|
189
226
|
hint: item.description ?? void 0
|
|
190
227
|
}))], project?.uuid ?? NEW);
|
|
191
|
-
project = uuid === NEW ? void 0 : find(projects, uuid);
|
|
228
|
+
project = uuid === NEW ? void 0 : find$1(projects, uuid);
|
|
192
229
|
}
|
|
193
230
|
if (!project) {
|
|
194
231
|
if (options.project && /^[a-z0-9]{20,}$/.test(options.project)) throw new Error(`Coolify has no project ${options.project}.`);
|
|
@@ -202,7 +239,7 @@ var CoolifyClient = class {
|
|
|
202
239
|
ask.step(`Created the Coolify project ${projectName}`);
|
|
203
240
|
}
|
|
204
241
|
const environmentName = options.environment ?? "production";
|
|
205
|
-
let environment = find(await api.get(`/projects/${enc(project.uuid)}/environments`), environmentName);
|
|
242
|
+
let environment = find$1(await api.get(`/projects/${enc(project.uuid)}/environments`), environmentName);
|
|
206
243
|
if (!environment) {
|
|
207
244
|
environment = {
|
|
208
245
|
uuid: (await api.post(`/projects/${enc(project.uuid)}/environments`, { name: environmentName })).uuid,
|
|
@@ -212,7 +249,7 @@ var CoolifyClient = class {
|
|
|
212
249
|
}
|
|
213
250
|
let githubApp;
|
|
214
251
|
if (options.githubApp && options.githubApp !== "public") {
|
|
215
|
-
const app = find(await api.get("/github-apps"), options.githubApp);
|
|
252
|
+
const app = find$1(await api.get("/github-apps"), options.githubApp);
|
|
216
253
|
if (!app) throw new Error(`Coolify has no GitHub App ${options.githubApp}.`);
|
|
217
254
|
githubApp = app.uuid;
|
|
218
255
|
} else if (!options.githubApp && ask.interactive) {
|
|
@@ -281,11 +318,11 @@ var CoolifyClient = class {
|
|
|
281
318
|
return id;
|
|
282
319
|
}
|
|
283
320
|
async deployment(link, id) {
|
|
284
|
-
return toDeployment(this.api, link, await this.api.get(`/deployments/${enc(id)}`));
|
|
321
|
+
return toDeployment$1(this.api, link, await this.api.get(`/deployments/${enc(id)}`));
|
|
285
322
|
}
|
|
286
323
|
async deployments(link, limit) {
|
|
287
324
|
const result = await this.api.get(`/deployments/applications/${enc(link.application)}?take=${limit}`);
|
|
288
|
-
return (Array.isArray(result) ? result : result?.deployments ?? []).map((item) => toDeployment(this.api, link, item));
|
|
325
|
+
return (Array.isArray(result) ? result : result?.deployments ?? []).map((item) => toDeployment$1(this.api, link, item));
|
|
289
326
|
}
|
|
290
327
|
async status(link) {
|
|
291
328
|
const app = await this.api.get(`/applications/${enc(link.application)}`);
|
|
@@ -301,28 +338,729 @@ var CoolifyClient = class {
|
|
|
301
338
|
const coolify = {
|
|
302
339
|
id: "coolify",
|
|
303
340
|
name: "Coolify",
|
|
304
|
-
|
|
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"),
|
|
305
348
|
platformEnv: PLATFORM_ENV,
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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));
|
|
323
997
|
}
|
|
324
|
-
|
|
325
|
-
|
|
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
|
+
}
|
|
326
1064
|
};
|
|
327
1065
|
// src/deploy/credentials.ts
|
|
328
1066
|
/**
|
|
@@ -523,23 +1261,27 @@ function unpushedReason(directory, branch) {
|
|
|
523
1261
|
`refs/remotes/origin/${branch}`
|
|
524
1262
|
]) === void 0) return `Your last commit is not on origin/${branch}, so the deploy would not include it. Push first: git push`;
|
|
525
1263
|
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
return
|
|
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.";
|
|
538
1276
|
}
|
|
539
1277
|
// src/deploy/index.ts
|
|
540
1278
|
const DEFAULT_PROVIDER = coolify;
|
|
541
1279
|
/** Every deploy provider. */
|
|
542
|
-
const PROVIDERS = [
|
|
1280
|
+
const PROVIDERS = [
|
|
1281
|
+
coolify,
|
|
1282
|
+
dokploy,
|
|
1283
|
+
ssh
|
|
1284
|
+
];
|
|
543
1285
|
function getProvider(id) {
|
|
544
1286
|
const provider = PROVIDERS.find((item) => item.id === id);
|
|
545
1287
|
if (!provider) throw new Error(`Unknown deploy provider ${id}. Choose one of: ${PROVIDERS.map((p) => p.id).join(", ")}.`);
|
|
@@ -822,8 +1564,13 @@ function deployEnv(directory, provider) {
|
|
|
822
1564
|
...provider.platformEnv
|
|
823
1565
|
};
|
|
824
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
|
+
}
|
|
825
1572
|
async function askToken(provider, instance) {
|
|
826
|
-
p.log.info(provider.
|
|
1573
|
+
p.log.info(tokenAuth(provider).help(instance));
|
|
827
1574
|
return unwrap(await p.password({
|
|
828
1575
|
message: `${provider.name} API key`,
|
|
829
1576
|
validate: (value) => value?.trim() ? void 0 : "Paste the API key."
|
|
@@ -836,14 +1583,15 @@ async function login(provider, instance, token) {
|
|
|
836
1583
|
const version = await attempt(provider.connect(instance, token).verify(), spinner);
|
|
837
1584
|
spinner.stop(`${provider.name} ${version} accepts the key`);
|
|
838
1585
|
if (saveToken(provider.id, instance, token) === "keychain") p.log.success("Saved the key in the system keychain.");
|
|
839
|
-
else p.log.warn(`There is no system keychain here, so the key is in ~/.config/dstack/credentials.json, readable only by you. Set ${provider.
|
|
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.`);
|
|
840
1587
|
return token;
|
|
841
1588
|
}
|
|
842
|
-
/** A client for a provider
|
|
1589
|
+
/** A client for a provider target: the saved key, or a new one when the CLI may ask. */
|
|
843
1590
|
async function connect(provider, instance, interactive) {
|
|
844
|
-
|
|
1591
|
+
if (provider.auth.kind === "ssh") return attempt(Promise.resolve().then(() => provider.connect(instance)));
|
|
1592
|
+
const saved = getToken(provider.id, instance, provider.auth.env);
|
|
845
1593
|
if (saved) return provider.connect(instance, saved.token);
|
|
846
|
-
if (!interactive) fail(`No ${provider.name} API key for ${instance}. Run ${command("dstack deploy login")} or set ${provider.
|
|
1594
|
+
if (!interactive) fail(`No ${provider.name} API key for ${instance}. Run ${command("dstack deploy login")} or set ${provider.auth.env}.`);
|
|
847
1595
|
const token = await login(provider, instance, await askToken(provider, instance));
|
|
848
1596
|
return provider.connect(instance, token);
|
|
849
1597
|
}
|
|
@@ -906,11 +1654,11 @@ async function deploySetup(options) {
|
|
|
906
1654
|
if (!instance) {
|
|
907
1655
|
if (!interactive) fail(`Pass --url, the address of your ${provider.name}.`);
|
|
908
1656
|
instance = unwrap(await p.text({
|
|
909
|
-
message:
|
|
910
|
-
placeholder:
|
|
1657
|
+
message: provider.target.label,
|
|
1658
|
+
placeholder: provider.target.placeholder,
|
|
911
1659
|
validate: (value) => {
|
|
912
1660
|
try {
|
|
913
|
-
|
|
1661
|
+
provider.target.normalize(value ?? "");
|
|
914
1662
|
} catch (error) {
|
|
915
1663
|
return error.message;
|
|
916
1664
|
}
|
|
@@ -918,7 +1666,7 @@ async function deploySetup(options) {
|
|
|
918
1666
|
}));
|
|
919
1667
|
}
|
|
920
1668
|
try {
|
|
921
|
-
instance =
|
|
1669
|
+
instance = provider.target.normalize(instance);
|
|
922
1670
|
} catch (error) {
|
|
923
1671
|
fail(error.message);
|
|
924
1672
|
}
|
|
@@ -926,15 +1674,17 @@ async function deploySetup(options) {
|
|
|
926
1674
|
const domain = (env.SITE_URL ?? "").replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
|
927
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")}.`);
|
|
928
1676
|
let repository = options.repository;
|
|
929
|
-
if (
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
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
|
+
}
|
|
938
1688
|
}
|
|
939
1689
|
const branch = options.branch ?? gitBranch(project.directory) ?? "main";
|
|
940
1690
|
const client = await connect(provider, instance, interactive);
|
|
@@ -957,12 +1707,12 @@ async function deploySetup(options) {
|
|
|
957
1707
|
};
|
|
958
1708
|
writeLink(project.directory, link);
|
|
959
1709
|
p.log.success(`Wrote ${pc.bold(LINK_FILE)}. It holds no secrets; commit it so others deploy to the same place.`);
|
|
960
|
-
p.note([`Point the DNS of ${pc.bold(domain)} at the ${provider.name} server.`, `Change variables in ${provider.name}, or in ${PRODUCTION_ENV} and run ${command("dstack deploy env push")}.`].join("\n"), "Next");
|
|
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");
|
|
961
1711
|
if (options.deploy ?? (interactive ? unwrap(await p.confirm({
|
|
962
1712
|
message: "Deploy now?",
|
|
963
1713
|
initialValue: true
|
|
964
1714
|
})) : false)) {
|
|
965
|
-
await startDeploy(project.directory, link, client, {
|
|
1715
|
+
await startDeploy(project.directory, provider, link, client, {
|
|
966
1716
|
force: false,
|
|
967
1717
|
wait: true
|
|
968
1718
|
});
|
|
@@ -1011,16 +1761,27 @@ async function follow(link, client, id) {
|
|
|
1011
1761
|
else spinner.error(summary);
|
|
1012
1762
|
return deployment;
|
|
1013
1763
|
}
|
|
1014
|
-
|
|
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) {
|
|
1015
1771
|
if (options.pushCheck !== false) {
|
|
1016
|
-
const
|
|
1017
|
-
const reason = status.branch ? unpushedReason(directory, status.branch) : void 0;
|
|
1772
|
+
const reason = await notDeployedReason(directory, provider, link, client);
|
|
1018
1773
|
if (reason) fail(`${reason} Or pass --no-push-check.`);
|
|
1019
1774
|
}
|
|
1020
|
-
|
|
1021
|
-
|
|
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) {
|
|
1022
1783
|
const deployment = await attempt(client.deployment(link, id));
|
|
1023
|
-
p.outro(`Started deploy ${pc.bold(id)}
|
|
1784
|
+
p.outro(`Started deploy ${pc.bold(id)}${deployment.url ? `: ${url(deployment.url)}` : ""}. Follow it with ${command("dstack deploy status --watch")}`);
|
|
1024
1785
|
return;
|
|
1025
1786
|
}
|
|
1026
1787
|
p.log.message(pc.dim("Ctrl+C stops following; the deploy goes on."));
|
|
@@ -1039,7 +1800,7 @@ async function deploy(options) {
|
|
|
1039
1800
|
const provider = providerOrFail(link.provider);
|
|
1040
1801
|
intro(`Deploy to ${provider.name}`);
|
|
1041
1802
|
const client = await connect(provider, link.url, isInteractive(options.yes));
|
|
1042
|
-
await startDeploy(project.directory, link, client, {
|
|
1803
|
+
await startDeploy(project.directory, provider, link, client, {
|
|
1043
1804
|
force: Boolean(options.force),
|
|
1044
1805
|
wait: options.wait,
|
|
1045
1806
|
pushCheck: options.pushCheck
|
|
@@ -1110,6 +1871,17 @@ async function deployLogs(id, options) {
|
|
|
1110
1871
|
process.stderr.write(pc.dim(`Deploy ${deployment.id}: ${deployment.status}\n`));
|
|
1111
1872
|
if (deployment.status === "failed") process.exitCode = 1;
|
|
1112
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
|
+
}
|
|
1113
1885
|
/** `dstack deploy env push`: upload .env.production to the platform again. */
|
|
1114
1886
|
async function deployEnvPush(options) {
|
|
1115
1887
|
const project = projectOrFail(options);
|
|
@@ -1128,18 +1900,19 @@ async function loginTarget(options, interactive) {
|
|
|
1128
1900
|
link = readLink(readProject(options.dir ?? ".").directory);
|
|
1129
1901
|
} catch {}
|
|
1130
1902
|
const provider = providerOrFail(options.provider ?? link?.provider ?? DEFAULT_PROVIDER.id);
|
|
1903
|
+
tokenAuth(provider);
|
|
1131
1904
|
let instance = options.url ?? link?.url;
|
|
1132
1905
|
if (!instance) {
|
|
1133
1906
|
if (!interactive) fail(`Pass --url, the address of your ${provider.name}.`);
|
|
1134
1907
|
instance = unwrap(await p.text({
|
|
1135
|
-
message:
|
|
1136
|
-
placeholder:
|
|
1908
|
+
message: provider.target.label,
|
|
1909
|
+
placeholder: provider.target.placeholder
|
|
1137
1910
|
}));
|
|
1138
1911
|
}
|
|
1139
1912
|
try {
|
|
1140
1913
|
return {
|
|
1141
1914
|
provider,
|
|
1142
|
-
instance:
|
|
1915
|
+
instance: provider.target.normalize(instance)
|
|
1143
1916
|
};
|
|
1144
1917
|
} catch (error) {
|
|
1145
1918
|
fail(error.message);
|
|
@@ -1150,9 +1923,10 @@ async function deployLogin(options) {
|
|
|
1150
1923
|
const interactive = isInteractive(options.yes);
|
|
1151
1924
|
intro("Deploy login");
|
|
1152
1925
|
const { provider, instance } = await loginTarget(options, interactive);
|
|
1153
|
-
|
|
1926
|
+
const auth = tokenAuth(provider);
|
|
1927
|
+
let token = options.token ?? (options.yes ? process.env[auth.env] : void 0);
|
|
1154
1928
|
if (!token) {
|
|
1155
|
-
if (!interactive) fail(`Pass --token or set ${
|
|
1929
|
+
if (!interactive) fail(`Pass --token or set ${auth.env}.`);
|
|
1156
1930
|
token = await askToken(provider, instance);
|
|
1157
1931
|
}
|
|
1158
1932
|
await login(provider, instance, token);
|
|
@@ -1263,7 +2037,7 @@ function updateCommand(location = import.meta.url, env = process.env) {
|
|
|
1263
2037
|
if (path.includes("/pnpm/global/")) return `pnpm add -g ${PACKAGE_NAME}@latest`;
|
|
1264
2038
|
if (path.includes("/.bun/install/global/")) return `bun add -g ${PACKAGE_NAME}@latest`;
|
|
1265
2039
|
if (env.npm_config_user_agent?.startsWith("pnpm")) return `pnpm add -g ${PACKAGE_NAME}@latest`;
|
|
1266
|
-
return `npm
|
|
2040
|
+
return `npm i -g ${PACKAGE_NAME}@latest`;
|
|
1267
2041
|
}
|
|
1268
2042
|
// src/global-install.ts
|
|
1269
2043
|
/** The name of the installed command. */
|
|
@@ -1309,8 +2083,8 @@ function installCommand(runner = "npm") {
|
|
|
1309
2083
|
];
|
|
1310
2084
|
return [
|
|
1311
2085
|
"npm",
|
|
1312
|
-
"
|
|
1313
|
-
"
|
|
2086
|
+
"i",
|
|
2087
|
+
"-g",
|
|
1314
2088
|
`${PACKAGE_NAME}@latest`
|
|
1315
2089
|
];
|
|
1316
2090
|
}
|
|
@@ -2227,9 +3001,9 @@ function createProgram() {
|
|
|
2227
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);
|
|
2228
3002
|
program.command("types").description("Generate the frontend's API types from the backend's schema").addOption(dirOption()).action(types);
|
|
2229
3003
|
const providerOption = () => new Option("--provider <provider>", `The platform (default: ${DEFAULT_PROVIDER.id})`).choices(PROVIDERS.map((provider) => provider.id));
|
|
2230
|
-
const deployCommand = program.command("deploy").description("Deploy to a
|
|
2231
|
-
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
|
|
2232
|
-
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").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);
|
|
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);
|
|
2233
3007
|
const seen = /* @__PURE__ */ new Set();
|
|
2234
3008
|
for (const flag of PROVIDERS.flatMap((provider) => provider.setupFlags)) {
|
|
2235
3009
|
if (seen.has(flag.name)) continue;
|
|
@@ -2239,6 +3013,7 @@ function createProgram() {
|
|
|
2239
3013
|
}
|
|
2240
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);
|
|
2241
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);
|
|
2242
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);
|
|
2243
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);
|
|
2244
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);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@django-stack/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "The production-ready Django, batteries included stack. Create and manage DjangoStack projects: a Django backend, a frontend, Postgres and Docker Compose in one command.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Leon van der Grient",
|
|
@@ -5,7 +5,7 @@ description = "The Django backend of {{project_name}}."
|
|
|
5
5
|
requires-python = ">=3.12"
|
|
6
6
|
dependencies = [
|
|
7
7
|
"Django>=6.1,<6.2",
|
|
8
|
-
"django-stack[server,s3]>=0.
|
|
8
|
+
"django-stack[server,s3]>=0.3.0",
|
|
9
9
|
"django-headless[schema]>=1.1.1",
|
|
10
10
|
"django-content-studio[images,huey]>=1.8.0",
|
|
11
11
|
]
|
package/templates/base/README.md
CHANGED
|
@@ -37,7 +37,7 @@ the frontend Astro's, with the source mounted from `backend/` and
|
|
|
37
37
|
| Run the tests | `npm test` |
|
|
38
38
|
| Check the setup | `npm run doctor` |
|
|
39
39
|
|
|
40
|
-
With the `dstack` command installed (`npm
|
|
40
|
+
With the `dstack` command installed (`npm i -g @django-stack/cli`),
|
|
41
41
|
`dstack dev`, `dstack check`, `dstack manage <command>` and `dstack types` do
|
|
42
42
|
the same from any directory in the project. See the
|
|
43
43
|
[CLI reference](https://dwarsbit.github.io/django-stack/docs/cli).
|
package/templates/base/gitignore
CHANGED