@django-stack/cli 0.1.0 → 0.2.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 +1224 -244
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -1
- package/dist/{stack-OTrWHXrO.js → stack-CTyqYWCV.js} +122 -34
- package/package.json +2 -1
- package/templates/backend/pyproject.toml +1 -1
- package/templates/base/AGENTS.md +2 -1
- package/templates/base/README.md +3 -3
- package/templates/base/compose.yaml +7 -28
- package/templates/base/env.example +7 -4
- package/templates/frontend-astro/package.json +1 -1
package/dist/bin.js
CHANGED
|
@@ -1,13 +1,742 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { D as
|
|
3
|
-
import {
|
|
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/coolify.ts
|
|
13
|
+
/**
|
|
14
|
+
* Coolify (https://coolify.io), through its API at /api/v1 with a bearer
|
|
15
|
+
* token. The project deploys as a Docker Compose application that Coolify
|
|
16
|
+
* builds on the server from the git repository; Coolify's proxy gets the
|
|
17
|
+
* certificate and forwards to the `proxy` service (Caddy) on port 80.
|
|
18
|
+
*/
|
|
19
|
+
/** Variables that put Caddy behind Coolify's proxy. See the docs page on platforms. */
|
|
20
|
+
const PLATFORM_ENV = {
|
|
21
|
+
CADDY_ADDRESS: ":80",
|
|
22
|
+
TRUSTED_PROXIES: "private_ranges",
|
|
23
|
+
NUM_PROXIES: "2",
|
|
24
|
+
HTTP_PORT: "127.0.0.1:8080",
|
|
25
|
+
HTTPS_PORT: "127.0.0.1:8443"
|
|
26
|
+
};
|
|
27
|
+
var CoolifyError = class extends Error {
|
|
28
|
+
status;
|
|
29
|
+
constructor(message, status) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.status = status;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
var CoolifyApi = class {
|
|
35
|
+
url;
|
|
36
|
+
token;
|
|
37
|
+
fetchImpl;
|
|
38
|
+
constructor(url, token, fetchImpl = fetch) {
|
|
39
|
+
this.url = url;
|
|
40
|
+
this.token = token;
|
|
41
|
+
this.fetchImpl = fetchImpl;
|
|
42
|
+
}
|
|
43
|
+
async request(method, path, body) {
|
|
44
|
+
let response;
|
|
45
|
+
try {
|
|
46
|
+
response = await this.fetchImpl(`${this.url}/api/v1${path}`, {
|
|
47
|
+
method,
|
|
48
|
+
headers: {
|
|
49
|
+
Authorization: `Bearer ${this.token}`,
|
|
50
|
+
Accept: "application/json",
|
|
51
|
+
...body === void 0 ? {} : { "Content-Type": "application/json" }
|
|
52
|
+
},
|
|
53
|
+
body: body === void 0 ? void 0 : JSON.stringify(body),
|
|
54
|
+
signal: AbortSignal.timeout(3e4)
|
|
55
|
+
});
|
|
56
|
+
} catch (error) {
|
|
57
|
+
throw new CoolifyError(`Could not reach Coolify at ${this.url}: ${error.message}`, 0);
|
|
58
|
+
}
|
|
59
|
+
const text = await response.text();
|
|
60
|
+
if (!response.ok) throw new CoolifyError(errorMessage(response.status, text, this.url), response.status);
|
|
61
|
+
if (!text) return void 0;
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(text);
|
|
64
|
+
} catch {
|
|
65
|
+
return text;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
get = (path) => this.request("GET", path);
|
|
69
|
+
post = (path, body) => this.request("POST", path, body ?? {});
|
|
70
|
+
patch = (path, body) => this.request("PATCH", path, body);
|
|
71
|
+
};
|
|
72
|
+
function errorMessage(status, text, url) {
|
|
73
|
+
if (status === 401) return `Coolify at ${url} rejected the API key. Run dstack deploy login to enter a new one.`;
|
|
74
|
+
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
|
+
if (status === 404 && text.includes("<html")) return `${url} does not answer like Coolify; check the URL.`;
|
|
76
|
+
let message = "";
|
|
77
|
+
try {
|
|
78
|
+
const body = JSON.parse(text);
|
|
79
|
+
message = body.message ?? "";
|
|
80
|
+
if (body.errors) message += ` ${Object.values(body.errors).flat().join(" ")}`;
|
|
81
|
+
} catch {
|
|
82
|
+
message = text.slice(0, 200);
|
|
83
|
+
}
|
|
84
|
+
return `Coolify answered ${status}${message ? `: ${message.trim()}` : "."}`;
|
|
85
|
+
}
|
|
86
|
+
const enc = encodeURIComponent;
|
|
87
|
+
function deploymentStatus(value) {
|
|
88
|
+
if (value === "queued") return "queued";
|
|
89
|
+
if (value === "in_progress") return "building";
|
|
90
|
+
if (value === "finished") return "finished";
|
|
91
|
+
if (value.startsWith("cancelled")) return "cancelled";
|
|
92
|
+
return "failed";
|
|
93
|
+
}
|
|
94
|
+
function appState(value) {
|
|
95
|
+
const [state = "", health = ""] = (value ?? "").split(/[:(]/);
|
|
96
|
+
if (state === "running") return health.startsWith("unhealthy") ? "degraded" : "running";
|
|
97
|
+
if (state === "degraded" || state === "restarting") return "degraded";
|
|
98
|
+
if (state === "exited" || state === "stopped") return "stopped";
|
|
99
|
+
return "unknown";
|
|
100
|
+
}
|
|
101
|
+
/** Coolify keeps the log as JSON text: a list of { output, type, hidden }. */
|
|
102
|
+
function parseLog(logs) {
|
|
103
|
+
if (!logs) return [];
|
|
104
|
+
let entries;
|
|
105
|
+
try {
|
|
106
|
+
entries = JSON.parse(logs);
|
|
107
|
+
} catch {
|
|
108
|
+
return logs.split("\n").map((text) => ({
|
|
109
|
+
text,
|
|
110
|
+
stderr: false
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
if (!Array.isArray(entries)) return [];
|
|
114
|
+
return entries.filter((entry) => !entry.hidden && entry.output).flatMap((entry) => String(entry.output).split("\n").map((text) => ({
|
|
115
|
+
text,
|
|
116
|
+
stderr: entry.type === "stderr"
|
|
117
|
+
})));
|
|
118
|
+
}
|
|
119
|
+
/** The domains of a Compose application, by service. */
|
|
120
|
+
function composeDomains(app) {
|
|
121
|
+
let value = app.docker_compose_domains;
|
|
122
|
+
if (typeof value === "string") try {
|
|
123
|
+
value = JSON.parse(value);
|
|
124
|
+
} catch {
|
|
125
|
+
value = null;
|
|
126
|
+
}
|
|
127
|
+
const domains = Object.values(value ?? {}).flatMap((service) => (service?.domain ?? "").split(",")).map((domain) => domain.trim()).filter(Boolean);
|
|
128
|
+
if (!domains.length && app.fqdn) domains.push(...app.fqdn.split(",").map((d) => d.trim()));
|
|
129
|
+
return domains.map((domain) => domain.replace(/:\d+$/, ""));
|
|
130
|
+
}
|
|
131
|
+
function appUrl(api, link) {
|
|
132
|
+
return `${api.url}/project/${link.project}/environment/${link.environment}/application/${link.application}`;
|
|
133
|
+
}
|
|
134
|
+
function toDeployment(api, link, item) {
|
|
135
|
+
const path = item.deployment_url;
|
|
136
|
+
return {
|
|
137
|
+
id: item.deployment_uuid,
|
|
138
|
+
status: deploymentStatus(item.status),
|
|
139
|
+
commit: item.commit && item.commit !== "HEAD" ? item.commit.slice(0, 7) : void 0,
|
|
140
|
+
message: item.commit_message?.split("\n")[0] || void 0,
|
|
141
|
+
createdAt: item.created_at,
|
|
142
|
+
updatedAt: item.updated_at,
|
|
143
|
+
url: path ? `${api.url}${path.startsWith("/") ? "" : "/"}${path}` : `${appUrl(api, link)}/deployment/${item.deployment_uuid}`,
|
|
144
|
+
log: parseLog(item.logs)
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/** Finds a name or uuid in a list. */
|
|
148
|
+
function find(items, wanted) {
|
|
149
|
+
return items.find((item) => item.uuid === wanted) ?? items.find((item) => item.name === wanted);
|
|
150
|
+
}
|
|
151
|
+
var CoolifyClient = class {
|
|
152
|
+
api;
|
|
153
|
+
constructor(url, token, fetchImpl) {
|
|
154
|
+
this.api = new CoolifyApi(url, token, fetchImpl);
|
|
155
|
+
}
|
|
156
|
+
async verify() {
|
|
157
|
+
const version = await this.api.get("/version");
|
|
158
|
+
await this.api.get("/servers");
|
|
159
|
+
return String(version).trim();
|
|
160
|
+
}
|
|
161
|
+
async setup(context) {
|
|
162
|
+
const { ask, options } = context;
|
|
163
|
+
const api = this.api;
|
|
164
|
+
const servers = await api.get("/servers");
|
|
165
|
+
if (!servers.length) throw new Error("This Coolify has no servers. Add one in Coolify first.");
|
|
166
|
+
let server;
|
|
167
|
+
if (options.server) {
|
|
168
|
+
server = find(servers, options.server);
|
|
169
|
+
if (!server) throw new Error(`Coolify has no server ${options.server}.`);
|
|
170
|
+
} else if (servers.length === 1) server = servers[0];
|
|
171
|
+
else server = find(servers, await ask.select("Server to deploy to", servers.map((item) => ({
|
|
172
|
+
value: item.uuid,
|
|
173
|
+
label: item.name,
|
|
174
|
+
hint: item.ip
|
|
175
|
+
}))));
|
|
176
|
+
if (!server) throw new Error("No server chosen.");
|
|
177
|
+
const projects = await api.get("/projects");
|
|
178
|
+
let project;
|
|
179
|
+
const projectName = options.project ?? context.name;
|
|
180
|
+
project = find(projects, projectName);
|
|
181
|
+
if (!options.project && ask.interactive && projects.length) {
|
|
182
|
+
const NEW = "";
|
|
183
|
+
const uuid = await ask.select("Coolify project", [...project ? [] : [{
|
|
184
|
+
value: NEW,
|
|
185
|
+
label: `Create ${projectName}`
|
|
186
|
+
}], ...projects.map((item) => ({
|
|
187
|
+
value: item.uuid,
|
|
188
|
+
label: item.name,
|
|
189
|
+
hint: item.description ?? void 0
|
|
190
|
+
}))], project?.uuid ?? NEW);
|
|
191
|
+
project = uuid === NEW ? void 0 : find(projects, uuid);
|
|
192
|
+
}
|
|
193
|
+
if (!project) {
|
|
194
|
+
if (options.project && /^[a-z0-9]{20,}$/.test(options.project)) throw new Error(`Coolify has no project ${options.project}.`);
|
|
195
|
+
project = {
|
|
196
|
+
uuid: (await api.post("/projects", {
|
|
197
|
+
name: projectName,
|
|
198
|
+
description: "DjangoStack"
|
|
199
|
+
})).uuid,
|
|
200
|
+
name: projectName
|
|
201
|
+
};
|
|
202
|
+
ask.step(`Created the Coolify project ${projectName}`);
|
|
203
|
+
}
|
|
204
|
+
const environmentName = options.environment ?? "production";
|
|
205
|
+
let environment = find(await api.get(`/projects/${enc(project.uuid)}/environments`), environmentName);
|
|
206
|
+
if (!environment) {
|
|
207
|
+
environment = {
|
|
208
|
+
uuid: (await api.post(`/projects/${enc(project.uuid)}/environments`, { name: environmentName })).uuid,
|
|
209
|
+
name: environmentName
|
|
210
|
+
};
|
|
211
|
+
ask.step(`Created the environment ${environmentName}`);
|
|
212
|
+
}
|
|
213
|
+
let githubApp;
|
|
214
|
+
if (options.githubApp && options.githubApp !== "public") {
|
|
215
|
+
const app = find(await api.get("/github-apps"), options.githubApp);
|
|
216
|
+
if (!app) throw new Error(`Coolify has no GitHub App ${options.githubApp}.`);
|
|
217
|
+
githubApp = app.uuid;
|
|
218
|
+
} else if (!options.githubApp && ask.interactive) {
|
|
219
|
+
const apps = (await api.get("/github-apps").catch(() => [])).filter((app) => app.uuid && app.name);
|
|
220
|
+
if (apps.length) {
|
|
221
|
+
const choice = await ask.select("How Coolify gets the code", [{
|
|
222
|
+
value: "public",
|
|
223
|
+
label: "Public repository",
|
|
224
|
+
hint: "no access needed"
|
|
225
|
+
}, ...apps.map((app) => ({
|
|
226
|
+
value: app.uuid,
|
|
227
|
+
label: `GitHub App ${app.name}`,
|
|
228
|
+
hint: "private repository"
|
|
229
|
+
}))], "public");
|
|
230
|
+
githubApp = choice === "public" ? void 0 : choice;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const body = {
|
|
234
|
+
project_uuid: project.uuid,
|
|
235
|
+
server_uuid: server.uuid,
|
|
236
|
+
environment_uuid: environment.uuid,
|
|
237
|
+
git_repository: context.repository,
|
|
238
|
+
git_branch: context.branch,
|
|
239
|
+
build_pack: "dockercompose",
|
|
240
|
+
docker_compose_location: "/compose.yaml",
|
|
241
|
+
docker_compose_domains: [{
|
|
242
|
+
name: "proxy",
|
|
243
|
+
domain: `https://${context.domain}:80`
|
|
244
|
+
}],
|
|
245
|
+
name: context.name,
|
|
246
|
+
description: "DjangoStack",
|
|
247
|
+
instant_deploy: false,
|
|
248
|
+
...githubApp ? { github_app_uuid: githubApp } : {}
|
|
249
|
+
};
|
|
250
|
+
const created = await api.post(githubApp ? "/applications/private-github-app" : "/applications/public", body);
|
|
251
|
+
ask.step(`Created the application ${context.name} from ${context.repository} (${context.branch})`);
|
|
252
|
+
const link = {
|
|
253
|
+
provider: "coolify",
|
|
254
|
+
url: api.url,
|
|
255
|
+
project: project.uuid,
|
|
256
|
+
environment: environment.uuid,
|
|
257
|
+
application: created.uuid
|
|
258
|
+
};
|
|
259
|
+
await this.pushEnv(link, context.env);
|
|
260
|
+
ask.step(`Set ${Object.keys(context.env).length} environment variables`);
|
|
261
|
+
return {
|
|
262
|
+
project: link.project,
|
|
263
|
+
environment: link.environment,
|
|
264
|
+
application: link.application
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
async pushEnv(link, env) {
|
|
268
|
+
const data = Object.entries(env).map(([key, value]) => ({
|
|
269
|
+
key,
|
|
270
|
+
value,
|
|
271
|
+
is_literal: true,
|
|
272
|
+
is_preview: false
|
|
273
|
+
}));
|
|
274
|
+
await this.api.patch(`/applications/${enc(link.application)}/envs/bulk`, { data });
|
|
275
|
+
}
|
|
276
|
+
async deploy(link, options) {
|
|
277
|
+
const query = `uuid=${enc(link.application)}${options.force ? "&force=true" : ""}`;
|
|
278
|
+
const result = await this.api.post(`/deploy?${query}`);
|
|
279
|
+
const id = result?.deployments?.[0]?.deployment_uuid;
|
|
280
|
+
if (!id) throw new Error(result?.deployments?.[0]?.message ?? "Coolify did not start a deployment.");
|
|
281
|
+
return id;
|
|
282
|
+
}
|
|
283
|
+
async deployment(link, id) {
|
|
284
|
+
return toDeployment(this.api, link, await this.api.get(`/deployments/${enc(id)}`));
|
|
285
|
+
}
|
|
286
|
+
async deployments(link, limit) {
|
|
287
|
+
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));
|
|
289
|
+
}
|
|
290
|
+
async status(link) {
|
|
291
|
+
const app = await this.api.get(`/applications/${enc(link.application)}`);
|
|
292
|
+
return {
|
|
293
|
+
state: appState(app.status),
|
|
294
|
+
detail: app.status ?? "unknown",
|
|
295
|
+
domains: composeDomains(app),
|
|
296
|
+
branch: app.git_branch,
|
|
297
|
+
url: appUrl(this.api, link)
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
const coolify = {
|
|
302
|
+
id: "coolify",
|
|
303
|
+
name: "Coolify",
|
|
304
|
+
tokenEnv: "DSTACK_COOLIFY_TOKEN",
|
|
305
|
+
platformEnv: PLATFORM_ENV,
|
|
306
|
+
tokenHelp: (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.`,
|
|
307
|
+
setupFlags: [
|
|
308
|
+
{
|
|
309
|
+
name: "server",
|
|
310
|
+
description: "The Coolify server, by name or uuid"
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
name: "project",
|
|
314
|
+
description: "The Coolify project, by name or uuid (default: the project name)"
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
name: "environment",
|
|
318
|
+
description: "The Coolify environment (default: production)"
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
name: "githubApp",
|
|
322
|
+
description: "A Coolify GitHub App for a private repository, or public"
|
|
323
|
+
}
|
|
324
|
+
],
|
|
325
|
+
connect: (url, token) => new CoolifyClient(url, token)
|
|
326
|
+
};
|
|
327
|
+
// src/deploy/credentials.ts
|
|
328
|
+
/**
|
|
329
|
+
* API keys of deploy providers, never in the project: the OS keychain
|
|
330
|
+
* (macOS Keychain, Windows Credential Manager, the Secret Service on Linux),
|
|
331
|
+
* or a file only the user can read where there is no keychain. An
|
|
332
|
+
* environment variable such as DSTACK_COOLIFY_TOKEN overrides both, for CI.
|
|
333
|
+
*/
|
|
334
|
+
const SERVICE = "dstack";
|
|
335
|
+
function systemKeyring() {
|
|
336
|
+
try {
|
|
337
|
+
const { Entry } = createRequire(import.meta.url)("@napi-rs/keyring");
|
|
338
|
+
return {
|
|
339
|
+
get: (account) => new Entry(SERVICE, account).getPassword() ?? void 0,
|
|
340
|
+
set: (account, token) => new Entry(SERVICE, account).setPassword(token),
|
|
341
|
+
delete: (account) => void new Entry(SERVICE, account).deletePassword()
|
|
342
|
+
};
|
|
343
|
+
} catch {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
/** The account a key is saved under: one per provider and instance. */
|
|
348
|
+
function accountFor(provider, url) {
|
|
349
|
+
return `${provider}:${url}`;
|
|
350
|
+
}
|
|
351
|
+
function credentialsFile() {
|
|
352
|
+
const base = process.env.XDG_CONFIG_HOME || process.platform === "win32" && process.env.APPDATA || join(homedir(), ".config");
|
|
353
|
+
return join(base, "dstack", "credentials.json");
|
|
354
|
+
}
|
|
355
|
+
function readFile() {
|
|
356
|
+
const path = credentialsFile();
|
|
357
|
+
if (!existsSync(path)) return {};
|
|
358
|
+
try {
|
|
359
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
360
|
+
} catch {
|
|
361
|
+
return {};
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
function writeFile(values) {
|
|
365
|
+
const path = credentialsFile();
|
|
366
|
+
mkdirSync(dirname(path), {
|
|
367
|
+
recursive: true,
|
|
368
|
+
mode: 448
|
|
369
|
+
});
|
|
370
|
+
writeFileSync(path, `${JSON.stringify(values, null, 2)}\n`, { mode: 384 });
|
|
371
|
+
chmodSync(path, 384);
|
|
372
|
+
}
|
|
373
|
+
function getToken(provider, url, envName) {
|
|
374
|
+
const fromEnv = process.env[envName];
|
|
375
|
+
if (fromEnv) return {
|
|
376
|
+
token: fromEnv,
|
|
377
|
+
source: "env"
|
|
378
|
+
};
|
|
379
|
+
const account = accountFor(provider, url);
|
|
380
|
+
const keyring = systemKeyring();
|
|
381
|
+
if (keyring) try {
|
|
382
|
+
const token = keyring.get(account);
|
|
383
|
+
if (token) return {
|
|
384
|
+
token,
|
|
385
|
+
source: "keychain"
|
|
386
|
+
};
|
|
387
|
+
} catch {}
|
|
388
|
+
const token = readFile()[account];
|
|
389
|
+
return token ? {
|
|
390
|
+
token,
|
|
391
|
+
source: "file"
|
|
392
|
+
} : void 0;
|
|
393
|
+
}
|
|
394
|
+
/** Saves a key in the keychain, or in the credentials file when there is none. */
|
|
395
|
+
function saveToken(provider, url, token) {
|
|
396
|
+
const account = accountFor(provider, url);
|
|
397
|
+
const keyring = systemKeyring();
|
|
398
|
+
if (keyring) try {
|
|
399
|
+
keyring.set(account, token);
|
|
400
|
+
removeFromFile(account);
|
|
401
|
+
return "keychain";
|
|
402
|
+
} catch {}
|
|
403
|
+
writeFile({
|
|
404
|
+
...readFile(),
|
|
405
|
+
[account]: token
|
|
406
|
+
});
|
|
407
|
+
return "file";
|
|
408
|
+
}
|
|
409
|
+
function removeFromFile(account) {
|
|
410
|
+
const values = readFile();
|
|
411
|
+
if (!(account in values)) return false;
|
|
412
|
+
delete values[account];
|
|
413
|
+
writeFile(values);
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
416
|
+
/** Removes a key from the keychain and the file; returns whether there was one. */
|
|
417
|
+
function deleteToken(provider, url) {
|
|
418
|
+
const account = accountFor(provider, url);
|
|
419
|
+
let removed = false;
|
|
420
|
+
const keyring = systemKeyring();
|
|
421
|
+
if (keyring) try {
|
|
422
|
+
if (keyring.get(account)) {
|
|
423
|
+
keyring.delete(account);
|
|
424
|
+
removed = true;
|
|
425
|
+
}
|
|
426
|
+
} catch {}
|
|
427
|
+
return removeFromFile(account) || removed;
|
|
428
|
+
}
|
|
429
|
+
// src/deploy/link.ts
|
|
430
|
+
/** Where a project deploys, relative to the project directory. It holds no secrets and can be committed. */
|
|
431
|
+
const LINK_FILE = ".dstack/deploy.json";
|
|
432
|
+
function readLink(directory) {
|
|
433
|
+
const path = join(directory, LINK_FILE);
|
|
434
|
+
if (!existsSync(path)) return void 0;
|
|
435
|
+
let link;
|
|
436
|
+
try {
|
|
437
|
+
link = JSON.parse(readFileSync(path, "utf8"));
|
|
438
|
+
} catch {
|
|
439
|
+
throw new Error(`${LINK_FILE} is not valid JSON.`);
|
|
440
|
+
}
|
|
441
|
+
for (const key of [
|
|
442
|
+
"provider",
|
|
443
|
+
"url",
|
|
444
|
+
"project",
|
|
445
|
+
"environment",
|
|
446
|
+
"application"
|
|
447
|
+
]) if (typeof link[key] !== "string" || !link[key]) throw new Error(`${LINK_FILE} has no ${key}.`);
|
|
448
|
+
return link;
|
|
449
|
+
}
|
|
450
|
+
function writeLink(directory, link) {
|
|
451
|
+
const path = join(directory, LINK_FILE);
|
|
452
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
453
|
+
const { provider, url, project, environment, application } = link;
|
|
454
|
+
writeFileSync(path, `${JSON.stringify({
|
|
455
|
+
provider,
|
|
456
|
+
url,
|
|
457
|
+
project,
|
|
458
|
+
environment,
|
|
459
|
+
application
|
|
460
|
+
}, null, 2)}\n`);
|
|
461
|
+
}
|
|
462
|
+
/** An https URL for a git remote: git@github.com:owner/repo.git becomes https://github.com/owner/repo. */
|
|
463
|
+
function httpsRepository(remote) {
|
|
464
|
+
const value = remote.trim();
|
|
465
|
+
const scp = value.match(/^[\w.-]+@([\w.-]+):(.+)$/);
|
|
466
|
+
let url;
|
|
467
|
+
if (scp) url = `https://${scp[1]}/${scp[2]}`;
|
|
468
|
+
else try {
|
|
469
|
+
const parsed = new URL(value);
|
|
470
|
+
url = `https://${parsed.host.replace(/:\d+$/, "")}${parsed.pathname}`;
|
|
471
|
+
} catch {
|
|
472
|
+
throw new Error(`Cannot read the git remote ${value}.`);
|
|
473
|
+
}
|
|
474
|
+
return url.replace(/\.git$/, "").replace(/\/$/, "");
|
|
475
|
+
}
|
|
476
|
+
function git(directory, args) {
|
|
477
|
+
try {
|
|
478
|
+
return execFileSync("git", args, {
|
|
479
|
+
cwd: directory,
|
|
480
|
+
encoding: "utf8",
|
|
481
|
+
stdio: [
|
|
482
|
+
"ignore",
|
|
483
|
+
"pipe",
|
|
484
|
+
"ignore"
|
|
485
|
+
]
|
|
486
|
+
}).trim();
|
|
487
|
+
} catch {
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
function gitRemote(directory, remote = "origin") {
|
|
492
|
+
return git(directory, [
|
|
493
|
+
"remote",
|
|
494
|
+
"get-url",
|
|
495
|
+
remote
|
|
496
|
+
]) || void 0;
|
|
497
|
+
}
|
|
498
|
+
function gitBranch(directory) {
|
|
499
|
+
const branch = git(directory, [
|
|
500
|
+
"rev-parse",
|
|
501
|
+
"--abbrev-ref",
|
|
502
|
+
"HEAD"
|
|
503
|
+
]);
|
|
504
|
+
return branch && branch !== "HEAD" ? branch : void 0;
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Why a deploy of `branch` would not include the local commit, or undefined
|
|
508
|
+
* when origin has it. The platform builds from the remote, not from here.
|
|
509
|
+
*/
|
|
510
|
+
function unpushedReason(directory, branch) {
|
|
511
|
+
const head = git(directory, ["rev-parse", "HEAD"]);
|
|
512
|
+
if (!head) return void 0;
|
|
513
|
+
if (git(directory, [
|
|
514
|
+
"rev-parse",
|
|
515
|
+
"--verify",
|
|
516
|
+
"--quiet",
|
|
517
|
+
`refs/remotes/origin/${branch}`
|
|
518
|
+
]) === void 0) return `origin has no branch ${branch}. Push it first: git push -u origin ${branch}`;
|
|
519
|
+
if (git(directory, [
|
|
520
|
+
"merge-base",
|
|
521
|
+
"--is-ancestor",
|
|
522
|
+
head,
|
|
523
|
+
`refs/remotes/origin/${branch}`
|
|
524
|
+
]) === void 0) return `Your last commit is not on origin/${branch}, so the deploy would not include it. Push first: git push`;
|
|
525
|
+
}
|
|
526
|
+
// src/deploy/provider.ts
|
|
527
|
+
/** Normalizes an instance URL: https by default, no trailing slash or path. */
|
|
528
|
+
function normalizeUrl(value) {
|
|
529
|
+
const trimmed = value.trim();
|
|
530
|
+
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
|
531
|
+
let parsed;
|
|
532
|
+
try {
|
|
533
|
+
parsed = new URL(withScheme);
|
|
534
|
+
} catch {
|
|
535
|
+
throw new Error(`${value} is not a URL. Enter one such as https://coolify.example.com.`);
|
|
536
|
+
}
|
|
537
|
+
return `${parsed.protocol}//${parsed.host}`;
|
|
538
|
+
}
|
|
539
|
+
// src/deploy/index.ts
|
|
540
|
+
const DEFAULT_PROVIDER = coolify;
|
|
541
|
+
/** Every deploy provider. */
|
|
542
|
+
const PROVIDERS = [coolify];
|
|
543
|
+
function getProvider(id) {
|
|
544
|
+
const provider = PROVIDERS.find((item) => item.id === id);
|
|
545
|
+
if (!provider) throw new Error(`Unknown deploy provider ${id}. Choose one of: ${PROVIDERS.map((p) => p.id).join(", ")}.`);
|
|
546
|
+
return provider;
|
|
547
|
+
}
|
|
548
|
+
// src/project.ts
|
|
549
|
+
/** The `.env` of `dstack run` on a development machine: the prod profile at https://site.localhost. */
|
|
550
|
+
const RUN_ENV = ".env.run";
|
|
551
|
+
const RUN_DOMAIN = "site.localhost";
|
|
552
|
+
/** The project directory: `start` or the nearest parent with `compose.yaml` and `.env`. */
|
|
553
|
+
function findProjectDirectory(start) {
|
|
554
|
+
let directory = resolve(start);
|
|
555
|
+
for (;;) {
|
|
556
|
+
if (existsSync(join(directory, "compose.yaml")) && existsSync(join(directory, ".env"))) return directory;
|
|
557
|
+
const parent = dirname(directory);
|
|
558
|
+
if (parent === directory) return void 0;
|
|
559
|
+
directory = parent;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
function parseProfiles(value) {
|
|
563
|
+
return (value ?? "").split(",").map((profile) => profile.trim()).filter(Boolean);
|
|
564
|
+
}
|
|
565
|
+
function projectName(directory) {
|
|
566
|
+
try {
|
|
567
|
+
const match = readFileSync(join(directory, "compose.yaml"), "utf8").match(/^name:\s*["']?([\w.-]+)/m);
|
|
568
|
+
if (match?.[1]) return match[1];
|
|
569
|
+
} catch {}
|
|
570
|
+
return basename(directory).toLowerCase();
|
|
571
|
+
}
|
|
572
|
+
function fromEnv(directory, name, envFile, localRun) {
|
|
573
|
+
const env = parseEnv(readFileSync(join(directory, envFile), "utf8"));
|
|
574
|
+
const profiles = parseProfiles(env.COMPOSE_PROFILES);
|
|
575
|
+
const prod = profiles.includes("prod");
|
|
576
|
+
const suffix = prod ? "-prod" : "";
|
|
577
|
+
return {
|
|
578
|
+
directory,
|
|
579
|
+
name: localRun ? `${name}-run` : name,
|
|
580
|
+
env,
|
|
581
|
+
profiles,
|
|
582
|
+
prod,
|
|
583
|
+
localRun,
|
|
584
|
+
services: {
|
|
585
|
+
backend: `backend${suffix}`,
|
|
586
|
+
frontend: `frontend${suffix}`,
|
|
587
|
+
worker: `worker${suffix}`
|
|
588
|
+
},
|
|
589
|
+
composeArgs: localRun ? [
|
|
590
|
+
"--env-file",
|
|
591
|
+
envFile,
|
|
592
|
+
"--project-name",
|
|
593
|
+
`${name}-run`
|
|
594
|
+
] : [],
|
|
595
|
+
composeEnv: localRun ? {
|
|
596
|
+
ENV_FILE: envFile,
|
|
597
|
+
COMPOSE_PROFILES: env.COMPOSE_PROFILES ?? ""
|
|
598
|
+
} : {}
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
/** Reads the project in or above `start`. Throws with a one-line reason when there is none. */
|
|
602
|
+
function readProject(start = ".") {
|
|
603
|
+
const directory = findProjectDirectory(start);
|
|
604
|
+
if (!directory) throw new Error(`No DjangoStack project in ${resolve(start)}: run this where compose.yaml and .env are.`);
|
|
605
|
+
return fromEnv(directory, projectName(directory), ".env", false);
|
|
606
|
+
}
|
|
607
|
+
/** The local production run of a development project, when `.env.run` exists. */
|
|
608
|
+
function readLocalRun(project) {
|
|
609
|
+
if (project.prod || project.localRun || !existsSync(join(project.directory, ".env.run"))) return void 0;
|
|
610
|
+
return fromEnv(project.directory, projectName(project.directory), RUN_ENV, true);
|
|
611
|
+
}
|
|
612
|
+
/** The `django-stack` section of the project's `package.json`. */
|
|
613
|
+
function projectManifest(project) {
|
|
614
|
+
try {
|
|
615
|
+
return JSON.parse(readFileSync(join(project.directory, "package.json"), "utf8"))["django-stack"] ?? {};
|
|
616
|
+
} catch {
|
|
617
|
+
return {};
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* The project's stack: from the preset `init` saved in `package.json`, or,
|
|
622
|
+
* for projects from before that, the defaults with the storage from `.env`.
|
|
623
|
+
*/
|
|
624
|
+
function projectStack(project) {
|
|
625
|
+
const { preset } = projectManifest(project);
|
|
626
|
+
if (preset) try {
|
|
627
|
+
return decodePreset(preset);
|
|
628
|
+
} catch {}
|
|
629
|
+
return {
|
|
630
|
+
...DEFAULT_STACK,
|
|
631
|
+
storage: project.profiles.includes("s3") ? "s3" : "local"
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
/** The URLs of a running project. */
|
|
635
|
+
function projectUrls(project) {
|
|
636
|
+
if (project.prod) {
|
|
637
|
+
const site = (project.env.SITE_URL ?? "").replace(/\/$/, "");
|
|
638
|
+
return [{
|
|
639
|
+
label: "Site",
|
|
640
|
+
url: `${site}/`
|
|
641
|
+
}, {
|
|
642
|
+
label: "Admin",
|
|
643
|
+
url: `${site}/admin/`
|
|
644
|
+
}];
|
|
645
|
+
}
|
|
646
|
+
const urls = [
|
|
647
|
+
{
|
|
648
|
+
label: "Site",
|
|
649
|
+
url: "http://localhost:3000"
|
|
650
|
+
},
|
|
651
|
+
{
|
|
652
|
+
label: "Admin",
|
|
653
|
+
url: "http://localhost:3000/admin/"
|
|
654
|
+
},
|
|
655
|
+
{
|
|
656
|
+
label: "Mail",
|
|
657
|
+
url: "http://localhost:8025"
|
|
658
|
+
}
|
|
659
|
+
];
|
|
660
|
+
if (project.profiles.includes("s3")) urls.push({
|
|
661
|
+
label: "S3",
|
|
662
|
+
url: "http://localhost:8333"
|
|
663
|
+
});
|
|
664
|
+
return urls;
|
|
665
|
+
}
|
|
666
|
+
/** Runs a command with the terminal attached and returns its exit status. */
|
|
667
|
+
const runInherit = (cmd, args, options) => {
|
|
668
|
+
const result = spawnSync(cmd, args, {
|
|
669
|
+
stdio: "inherit",
|
|
670
|
+
...options
|
|
671
|
+
});
|
|
672
|
+
if (result.error) return 127;
|
|
673
|
+
return result.status ?? 1;
|
|
674
|
+
};
|
|
675
|
+
/** The full `docker compose` argument list for a project. */
|
|
676
|
+
function composeCommand(project, args) {
|
|
677
|
+
return [
|
|
678
|
+
"compose",
|
|
679
|
+
...project.composeArgs,
|
|
680
|
+
...args
|
|
681
|
+
];
|
|
682
|
+
}
|
|
683
|
+
function composeOptions(project) {
|
|
684
|
+
return {
|
|
685
|
+
cwd: project.directory,
|
|
686
|
+
env: {
|
|
687
|
+
...process.env,
|
|
688
|
+
...project.composeEnv
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
/** Runs `docker compose <args>` for the project with the terminal attached. */
|
|
693
|
+
function compose(project, args, run = runInherit) {
|
|
694
|
+
return run("docker", composeCommand(project, args), composeOptions(project));
|
|
695
|
+
}
|
|
696
|
+
/** Runs `docker compose <args>` and captures the output. */
|
|
697
|
+
function composeOutput(project, args) {
|
|
698
|
+
const result = spawnSync("docker", composeCommand(project, args), {
|
|
699
|
+
...composeOptions(project),
|
|
700
|
+
encoding: "utf8",
|
|
701
|
+
stdio: [
|
|
702
|
+
"ignore",
|
|
703
|
+
"pipe",
|
|
704
|
+
"pipe"
|
|
705
|
+
]
|
|
706
|
+
});
|
|
707
|
+
return {
|
|
708
|
+
status: result.status ?? 1,
|
|
709
|
+
output: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim()
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
/** The running services of the project. */
|
|
713
|
+
function runningServices(project) {
|
|
714
|
+
const result = spawnSync("docker", composeCommand(project, [
|
|
715
|
+
"ps",
|
|
716
|
+
"--services",
|
|
717
|
+
"--filter",
|
|
718
|
+
"status=running"
|
|
719
|
+
]), {
|
|
720
|
+
...composeOptions(project),
|
|
721
|
+
encoding: "utf8",
|
|
722
|
+
stdio: [
|
|
723
|
+
"ignore",
|
|
724
|
+
"pipe",
|
|
725
|
+
"ignore"
|
|
726
|
+
]
|
|
727
|
+
});
|
|
728
|
+
return result.status === 0 ? result.stdout.split("\n").filter(Boolean) : [];
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* The project a command acts on: the local production run when it is the
|
|
732
|
+
* one running, otherwise the project `.env` describes.
|
|
733
|
+
*/
|
|
734
|
+
function activeProject(project) {
|
|
735
|
+
const localRun = readLocalRun(project);
|
|
736
|
+
if (!localRun) return project;
|
|
737
|
+
if (runningServices(project).length) return project;
|
|
738
|
+
return runningServices(localRun).length ? localRun : project;
|
|
739
|
+
}
|
|
11
740
|
// src/ui.ts
|
|
12
741
|
/** The DjangoStack badge with the version and an optional title. */
|
|
13
742
|
function intro(title) {
|
|
@@ -18,24 +747,422 @@ function fail(message) {
|
|
|
18
747
|
p.cancel(pc.red(message));
|
|
19
748
|
process.exit(1);
|
|
20
749
|
}
|
|
21
|
-
function unwrap(value) {
|
|
22
|
-
if (p.isCancel(value)) fail("Cancelled.");
|
|
23
|
-
return value;
|
|
750
|
+
function unwrap(value) {
|
|
751
|
+
if (p.isCancel(value)) fail("Cancelled.");
|
|
752
|
+
return value;
|
|
753
|
+
}
|
|
754
|
+
function has(command, args = ["--version"]) {
|
|
755
|
+
try {
|
|
756
|
+
execFileSync(command, args, { stdio: "ignore" });
|
|
757
|
+
return true;
|
|
758
|
+
} catch {
|
|
759
|
+
return false;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
const url = (value) => pc.cyan(pc.underline(value));
|
|
763
|
+
const command = (value) => pc.cyan(value);
|
|
764
|
+
const secret = (value) => pc.bold(pc.yellow(value));
|
|
765
|
+
/** Prompts only when stdin is a terminal and `--yes` was not passed. */
|
|
766
|
+
function isInteractive(yes) {
|
|
767
|
+
return !yes && Boolean(process.stdin.isTTY);
|
|
768
|
+
}
|
|
769
|
+
/** Logs a check, with its help lines when it did not pass. */
|
|
770
|
+
function logCheck(check) {
|
|
771
|
+
const head = check.detail ? `${check.name} ${pc.dim(`- ${check.detail}`)}` : check.name;
|
|
772
|
+
const help = !check.ok && check.help ? check.help.map((line) => pc.dim(line)) : [];
|
|
773
|
+
const text = [check.ok ? head : check.warning ? pc.yellow(head) : pc.red(head), ...help].join("\n");
|
|
774
|
+
if (check.ok) p.log.success(text);
|
|
775
|
+
else if (check.warning) p.log.warn(text);
|
|
776
|
+
else p.log.error(text);
|
|
777
|
+
}
|
|
778
|
+
// src/commands/deploy.ts
|
|
779
|
+
const PRODUCTION_ENV = ".env.production";
|
|
780
|
+
function projectOrFail(options) {
|
|
781
|
+
try {
|
|
782
|
+
return readProject(options.dir ?? ".");
|
|
783
|
+
} catch (error) {
|
|
784
|
+
fail(error.message);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
function linkOrFail(directory) {
|
|
788
|
+
let link;
|
|
789
|
+
try {
|
|
790
|
+
link = readLink(directory);
|
|
791
|
+
} catch (error) {
|
|
792
|
+
fail(error.message);
|
|
793
|
+
}
|
|
794
|
+
if (!link) fail(`This project is not set up for deploys yet. Run ${command("dstack deploy setup")}.`);
|
|
795
|
+
return link;
|
|
796
|
+
}
|
|
797
|
+
function providerOrFail(id) {
|
|
798
|
+
try {
|
|
799
|
+
return getProvider(id);
|
|
800
|
+
} catch (error) {
|
|
801
|
+
fail(error.message);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
/** Runs a provider call; a failure ends the command with its one-line reason. */
|
|
805
|
+
async function attempt(work, spinner) {
|
|
806
|
+
try {
|
|
807
|
+
return await work;
|
|
808
|
+
} catch (error) {
|
|
809
|
+
spinner?.error("Failed");
|
|
810
|
+
fail(error.message);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
/** The variables for the platform: .env.production plus what the platform needs. */
|
|
814
|
+
function deployEnv(directory, provider) {
|
|
815
|
+
let file = join(directory, PRODUCTION_ENV);
|
|
816
|
+
if (!existsSync(file)) {
|
|
817
|
+
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.`);
|
|
818
|
+
file = join(directory, ".env");
|
|
819
|
+
}
|
|
820
|
+
return {
|
|
821
|
+
...parseEnv(readFileSync(file, "utf8")),
|
|
822
|
+
...provider.platformEnv
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
async function askToken(provider, instance) {
|
|
826
|
+
p.log.info(provider.tokenHelp(instance));
|
|
827
|
+
return unwrap(await p.password({
|
|
828
|
+
message: `${provider.name} API key`,
|
|
829
|
+
validate: (value) => value?.trim() ? void 0 : "Paste the API key."
|
|
830
|
+
})).trim();
|
|
831
|
+
}
|
|
832
|
+
/** Checks a key and saves it; returns it. */
|
|
833
|
+
async function login(provider, instance, token) {
|
|
834
|
+
const spinner = p.spinner();
|
|
835
|
+
spinner.start(`Checking the key with ${instance}`);
|
|
836
|
+
const version = await attempt(provider.connect(instance, token).verify(), spinner);
|
|
837
|
+
spinner.stop(`${provider.name} ${version} accepts the key`);
|
|
838
|
+
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.tokenEnv} instead to keep it off disk.`);
|
|
840
|
+
return token;
|
|
841
|
+
}
|
|
842
|
+
/** A client for a provider instance: the saved key, or a new one when the CLI may ask. */
|
|
843
|
+
async function connect(provider, instance, interactive) {
|
|
844
|
+
const saved = getToken(provider.id, instance, provider.tokenEnv);
|
|
845
|
+
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.tokenEnv}.`);
|
|
847
|
+
const token = await login(provider, instance, await askToken(provider, instance));
|
|
848
|
+
return provider.connect(instance, token);
|
|
849
|
+
}
|
|
850
|
+
function promptAsk(interactive) {
|
|
851
|
+
return {
|
|
852
|
+
interactive,
|
|
853
|
+
async select(message, choices, initial) {
|
|
854
|
+
if (!interactive) {
|
|
855
|
+
if (initial !== void 0) return initial;
|
|
856
|
+
fail(`${message}: pass it as a flag; see ${command("dstack deploy setup --help")}.`);
|
|
857
|
+
}
|
|
858
|
+
return unwrap(await p.select({
|
|
859
|
+
message,
|
|
860
|
+
initialValue: initial,
|
|
861
|
+
options: choices.map((choice) => ({
|
|
862
|
+
value: choice.value,
|
|
863
|
+
label: choice.label,
|
|
864
|
+
hint: choice.hint
|
|
865
|
+
}))
|
|
866
|
+
}));
|
|
867
|
+
},
|
|
868
|
+
async text(message, options = {}) {
|
|
869
|
+
if (!interactive) {
|
|
870
|
+
if (options.initial) return options.initial;
|
|
871
|
+
fail(`${message}: pass it as a flag.`);
|
|
872
|
+
}
|
|
873
|
+
return unwrap(await p.text({
|
|
874
|
+
message,
|
|
875
|
+
placeholder: options.placeholder,
|
|
876
|
+
initialValue: options.initial
|
|
877
|
+
}));
|
|
878
|
+
},
|
|
879
|
+
async confirm(message, initial = true) {
|
|
880
|
+
if (!interactive) return initial;
|
|
881
|
+
return unwrap(await p.confirm({
|
|
882
|
+
message,
|
|
883
|
+
initialValue: initial
|
|
884
|
+
}));
|
|
885
|
+
},
|
|
886
|
+
step: (message) => p.log.success(message)
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
/** `dstack deploy setup`: create the application on the platform and link the project to it. */
|
|
890
|
+
async function deploySetup(options) {
|
|
891
|
+
const interactive = isInteractive(options.yes);
|
|
892
|
+
const project = projectOrFail(options);
|
|
893
|
+
intro("Deploy setup");
|
|
894
|
+
const existing = existsSync(join(project.directory, ".dstack/deploy.json")) ? readLink(project.directory) : void 0;
|
|
895
|
+
if (existing && !options.force) fail(`This project already deploys to ${existing.url} (${LINK_FILE}). Pass --force to set it up again.`);
|
|
896
|
+
let providerId = options.provider;
|
|
897
|
+
if (!providerId) providerId = PROVIDERS.length === 1 || !interactive ? DEFAULT_PROVIDER.id : unwrap(await p.select({
|
|
898
|
+
message: "Platform",
|
|
899
|
+
options: PROVIDERS.map((item) => ({
|
|
900
|
+
value: item.id,
|
|
901
|
+
label: item.name
|
|
902
|
+
}))
|
|
903
|
+
}));
|
|
904
|
+
const provider = providerOrFail(providerId);
|
|
905
|
+
let instance = options.url;
|
|
906
|
+
if (!instance) {
|
|
907
|
+
if (!interactive) fail(`Pass --url, the address of your ${provider.name}.`);
|
|
908
|
+
instance = unwrap(await p.text({
|
|
909
|
+
message: `${provider.name} URL`,
|
|
910
|
+
placeholder: "https://coolify.example.com",
|
|
911
|
+
validate: (value) => {
|
|
912
|
+
try {
|
|
913
|
+
normalizeUrl(value ?? "");
|
|
914
|
+
} catch (error) {
|
|
915
|
+
return error.message;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
}));
|
|
919
|
+
}
|
|
920
|
+
try {
|
|
921
|
+
instance = normalizeUrl(instance);
|
|
922
|
+
} catch (error) {
|
|
923
|
+
fail(error.message);
|
|
924
|
+
}
|
|
925
|
+
const env = deployEnv(project.directory, provider);
|
|
926
|
+
const domain = (env.SITE_URL ?? "").replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
|
927
|
+
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
|
+
let repository = options.repository;
|
|
929
|
+
if (!repository) {
|
|
930
|
+
const remote = gitRemote(project.directory);
|
|
931
|
+
if (!remote) fail("This project has no git remote origin. Push it to GitHub first, or pass --repository.");
|
|
932
|
+
repository = remote;
|
|
933
|
+
}
|
|
934
|
+
try {
|
|
935
|
+
repository = httpsRepository(repository);
|
|
936
|
+
} catch (error) {
|
|
937
|
+
fail(error.message);
|
|
938
|
+
}
|
|
939
|
+
const branch = options.branch ?? gitBranch(project.directory) ?? "main";
|
|
940
|
+
const client = await connect(provider, instance, interactive);
|
|
941
|
+
await attempt(client.verify());
|
|
942
|
+
const flags = {};
|
|
943
|
+
for (const flag of provider.setupFlags) flags[flag.name] = options[flag.name];
|
|
944
|
+
const ids = await attempt(client.setup({
|
|
945
|
+
ask: promptAsk(interactive),
|
|
946
|
+
name: project.name,
|
|
947
|
+
repository,
|
|
948
|
+
branch,
|
|
949
|
+
domain,
|
|
950
|
+
env,
|
|
951
|
+
options: flags
|
|
952
|
+
}));
|
|
953
|
+
const link = {
|
|
954
|
+
provider: provider.id,
|
|
955
|
+
url: instance,
|
|
956
|
+
...ids
|
|
957
|
+
};
|
|
958
|
+
writeLink(project.directory, link);
|
|
959
|
+
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");
|
|
961
|
+
if (options.deploy ?? (interactive ? unwrap(await p.confirm({
|
|
962
|
+
message: "Deploy now?",
|
|
963
|
+
initialValue: true
|
|
964
|
+
})) : false)) {
|
|
965
|
+
await startDeploy(project.directory, link, client, {
|
|
966
|
+
force: false,
|
|
967
|
+
wait: true
|
|
968
|
+
});
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
p.outro(`Deploy with ${command("dstack deploy")}`);
|
|
972
|
+
}
|
|
973
|
+
const STATUS_LABEL = {
|
|
974
|
+
queued: pc.dim("queued"),
|
|
975
|
+
building: pc.yellow("building"),
|
|
976
|
+
finished: pc.green("finished"),
|
|
977
|
+
failed: pc.red("failed"),
|
|
978
|
+
cancelled: pc.dim("cancelled")
|
|
979
|
+
};
|
|
980
|
+
const DONE = /* @__PURE__ */ new Set([
|
|
981
|
+
"finished",
|
|
982
|
+
"failed",
|
|
983
|
+
"cancelled"
|
|
984
|
+
]);
|
|
985
|
+
const POLL_MS = 3e3;
|
|
986
|
+
const WAIT_LIMIT_MS = 27e5;
|
|
987
|
+
function printLog(deployment, lines) {
|
|
988
|
+
const log = lines ? deployment.log.slice(-lines) : deployment.log;
|
|
989
|
+
for (const line of log) process.stdout.write(`${line.stderr ? pc.red(line.text) : line.text}\n`);
|
|
990
|
+
}
|
|
991
|
+
async function follow(link, client, id) {
|
|
992
|
+
const spinner = p.spinner();
|
|
993
|
+
spinner.start("Queued");
|
|
994
|
+
const started = Date.now();
|
|
995
|
+
let deployment;
|
|
996
|
+
for (;;) {
|
|
997
|
+
deployment = await attempt(client.deployment(link, id), spinner);
|
|
998
|
+
if (DONE.has(deployment.status)) break;
|
|
999
|
+
const last = deployment.log.at(-1)?.text.trim().slice(0, 70);
|
|
1000
|
+
const seconds = Math.round((Date.now() - started) / 1e3);
|
|
1001
|
+
spinner.message(`${deployment.status === "queued" ? "Queued" : "Building"} ${pc.dim(`${seconds}s`)}${last ? pc.dim(` ${last}`) : ""}`);
|
|
1002
|
+
if (Date.now() - started > WAIT_LIMIT_MS) {
|
|
1003
|
+
spinner.stop("Still running");
|
|
1004
|
+
fail(`The deploy takes longer than 45 minutes. Follow it with ${command("dstack deploy status")}.`);
|
|
1005
|
+
}
|
|
1006
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
|
|
1007
|
+
}
|
|
1008
|
+
const seconds = Math.round((Date.now() - started) / 1e3);
|
|
1009
|
+
const summary = `${STATUS_LABEL[deployment.status]} after ${seconds}s`;
|
|
1010
|
+
if (deployment.status === "finished") spinner.stop(summary);
|
|
1011
|
+
else spinner.error(summary);
|
|
1012
|
+
return deployment;
|
|
1013
|
+
}
|
|
1014
|
+
async function startDeploy(directory, link, client, options) {
|
|
1015
|
+
if (options.pushCheck !== false) {
|
|
1016
|
+
const status = await attempt(client.status(link));
|
|
1017
|
+
const reason = status.branch ? unpushedReason(directory, status.branch) : void 0;
|
|
1018
|
+
if (reason) fail(`${reason} Or pass --no-push-check.`);
|
|
1019
|
+
}
|
|
1020
|
+
const id = await attempt(client.deploy(link, { force: options.force }));
|
|
1021
|
+
if (!options.wait) {
|
|
1022
|
+
const deployment = await attempt(client.deployment(link, id));
|
|
1023
|
+
p.outro(`Started deploy ${pc.bold(id)}: ${url(deployment.url ?? link.url)}`);
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
p.log.message(pc.dim("Ctrl+C stops following; the deploy goes on."));
|
|
1027
|
+
const deployment = await follow(link, client, id);
|
|
1028
|
+
if (deployment.status !== "finished") {
|
|
1029
|
+
printLog(deployment, 40);
|
|
1030
|
+
fail(`The deploy ${deployment.status === "cancelled" ? "was cancelled" : "failed"}. Full log: ${command(`dstack deploy logs ${id}`)}`);
|
|
1031
|
+
}
|
|
1032
|
+
const status = await attempt(client.status(link));
|
|
1033
|
+
p.outro(`Deployed${status.domains.length ? `: ${status.domains.map(url).join(" ")}` : ""}`);
|
|
1034
|
+
}
|
|
1035
|
+
/** `dstack deploy`: start a deploy and follow it. */
|
|
1036
|
+
async function deploy(options) {
|
|
1037
|
+
const project = projectOrFail(options);
|
|
1038
|
+
const link = linkOrFail(project.directory);
|
|
1039
|
+
const provider = providerOrFail(link.provider);
|
|
1040
|
+
intro(`Deploy to ${provider.name}`);
|
|
1041
|
+
const client = await connect(provider, link.url, isInteractive(options.yes));
|
|
1042
|
+
await startDeploy(project.directory, link, client, {
|
|
1043
|
+
force: Boolean(options.force),
|
|
1044
|
+
wait: options.wait,
|
|
1045
|
+
pushCheck: options.pushCheck
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
function ago(date) {
|
|
1049
|
+
if (!date) return "";
|
|
1050
|
+
const seconds = Math.max(0, Math.round((Date.now() - new Date(date).getTime()) / 1e3));
|
|
1051
|
+
if (Number.isNaN(seconds)) return "";
|
|
1052
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
1053
|
+
if (seconds < 3600) return `${Math.round(seconds / 60)} min ago`;
|
|
1054
|
+
if (seconds < 86400) return `${Math.round(seconds / 3600)} h ago`;
|
|
1055
|
+
return `${Math.round(seconds / 86400)} d ago`;
|
|
1056
|
+
}
|
|
1057
|
+
const STATE_LABEL = {
|
|
1058
|
+
running: pc.green("running"),
|
|
1059
|
+
degraded: pc.yellow("degraded"),
|
|
1060
|
+
stopped: pc.red("stopped"),
|
|
1061
|
+
unknown: pc.dim("unknown")
|
|
1062
|
+
};
|
|
1063
|
+
/** `dstack deploy status`: the application's state and its last deploys. */
|
|
1064
|
+
async function deployStatus(options) {
|
|
1065
|
+
const project = projectOrFail(options);
|
|
1066
|
+
const link = linkOrFail(project.directory);
|
|
1067
|
+
const provider = providerOrFail(link.provider);
|
|
1068
|
+
intro(`${provider.name} status`);
|
|
1069
|
+
const client = await connect(provider, link.url, isInteractive(false));
|
|
1070
|
+
const limit = Math.max(1, Number.parseInt(options.limit, 10) || 5);
|
|
1071
|
+
const [status, deployments] = await Promise.all([attempt(client.status(link)), attempt(client.deployments(link, limit))]);
|
|
1072
|
+
p.note([
|
|
1073
|
+
`${pc.bold("State")} ${STATE_LABEL[status.state]} ${pc.dim(`(${status.detail})`)}`,
|
|
1074
|
+
...status.domains.length ? [`${pc.bold("Site")} ${status.domains.map(url).join(" ")}`] : [],
|
|
1075
|
+
...status.branch ? [`${pc.bold("Branch")} ${status.branch}`] : [],
|
|
1076
|
+
...status.url ? [`${pc.bold(provider.name.padEnd(7))} ${url(status.url)}`] : []
|
|
1077
|
+
].join("\n"), project.name);
|
|
1078
|
+
if (deployments.length) p.log.message(deployments.map((item) => [
|
|
1079
|
+
STATUS_LABEL[item.status].padEnd(18),
|
|
1080
|
+
pc.dim(ago(item.createdAt).padEnd(11)),
|
|
1081
|
+
item.commit ? pc.cyan(item.commit) : pc.dim("-------"),
|
|
1082
|
+
item.message ?? ""
|
|
1083
|
+
].join(" ")).join("\n"), { symbol: pc.dim("│") });
|
|
1084
|
+
else p.log.message(pc.dim("No deploys yet."));
|
|
1085
|
+
const running = deployments.find((item) => !DONE.has(item.status));
|
|
1086
|
+
if (running && options.watch) {
|
|
1087
|
+
const deployment = await follow(link, client, running.id);
|
|
1088
|
+
if (deployment.status !== "finished") {
|
|
1089
|
+
printLog(deployment, 40);
|
|
1090
|
+
fail(`The deploy failed. Full log: ${command(`dstack deploy logs ${running.id}`)}`);
|
|
1091
|
+
}
|
|
1092
|
+
} else if (running) {
|
|
1093
|
+
p.outro(`A deploy is ${running.status}. Follow it with ${command("dstack deploy status --watch")}`);
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
p.outro(pc.green("Done"));
|
|
24
1097
|
}
|
|
25
|
-
|
|
1098
|
+
/** `dstack deploy logs [deployment]`: the build log of a deploy, the latest by default. */
|
|
1099
|
+
async function deployLogs(id, options) {
|
|
1100
|
+
const link = linkOrFail(projectOrFail(options).directory);
|
|
1101
|
+
const client = await connect(providerOrFail(link.provider), link.url, isInteractive(false));
|
|
1102
|
+
let deploymentId = id;
|
|
1103
|
+
if (!deploymentId) {
|
|
1104
|
+
const [latest] = await attempt(client.deployments(link, 1));
|
|
1105
|
+
if (!latest) fail("There are no deploys yet.");
|
|
1106
|
+
deploymentId = latest.id;
|
|
1107
|
+
}
|
|
1108
|
+
const deployment = await attempt(client.deployment(link, deploymentId));
|
|
1109
|
+
printLog(deployment);
|
|
1110
|
+
process.stderr.write(pc.dim(`Deploy ${deployment.id}: ${deployment.status}\n`));
|
|
1111
|
+
if (deployment.status === "failed") process.exitCode = 1;
|
|
1112
|
+
}
|
|
1113
|
+
/** `dstack deploy env push`: upload .env.production to the platform again. */
|
|
1114
|
+
async function deployEnvPush(options) {
|
|
1115
|
+
const project = projectOrFail(options);
|
|
1116
|
+
const link = linkOrFail(project.directory);
|
|
1117
|
+
const provider = providerOrFail(link.provider);
|
|
1118
|
+
intro("Push variables");
|
|
1119
|
+
const interactive = isInteractive(options.yes);
|
|
1120
|
+
const env = deployEnv(project.directory, provider);
|
|
1121
|
+
if (interactive && !unwrap(await p.confirm({ message: `Replace ${Object.keys(env).length} variables in ${provider.name} with the values from ${PRODUCTION_ENV}?` }))) fail("Cancelled.");
|
|
1122
|
+
await attempt((await connect(provider, link.url, interactive)).pushEnv(link, env));
|
|
1123
|
+
p.outro(`Set ${Object.keys(env).length} variables. They apply on the next ${command("dstack deploy")}`);
|
|
1124
|
+
}
|
|
1125
|
+
async function loginTarget(options, interactive) {
|
|
1126
|
+
let link;
|
|
26
1127
|
try {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
1128
|
+
link = readLink(readProject(options.dir ?? ".").directory);
|
|
1129
|
+
} catch {}
|
|
1130
|
+
const provider = providerOrFail(options.provider ?? link?.provider ?? DEFAULT_PROVIDER.id);
|
|
1131
|
+
let instance = options.url ?? link?.url;
|
|
1132
|
+
if (!instance) {
|
|
1133
|
+
if (!interactive) fail(`Pass --url, the address of your ${provider.name}.`);
|
|
1134
|
+
instance = unwrap(await p.text({
|
|
1135
|
+
message: `${provider.name} URL`,
|
|
1136
|
+
placeholder: "https://coolify.example.com"
|
|
1137
|
+
}));
|
|
1138
|
+
}
|
|
1139
|
+
try {
|
|
1140
|
+
return {
|
|
1141
|
+
provider,
|
|
1142
|
+
instance: normalizeUrl(instance)
|
|
1143
|
+
};
|
|
1144
|
+
} catch (error) {
|
|
1145
|
+
fail(error.message);
|
|
31
1146
|
}
|
|
32
1147
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
1148
|
+
/** `dstack deploy login`: check and save an API key. */
|
|
1149
|
+
async function deployLogin(options) {
|
|
1150
|
+
const interactive = isInteractive(options.yes);
|
|
1151
|
+
intro("Deploy login");
|
|
1152
|
+
const { provider, instance } = await loginTarget(options, interactive);
|
|
1153
|
+
let token = options.token ?? (options.yes ? process.env[provider.tokenEnv] : void 0);
|
|
1154
|
+
if (!token) {
|
|
1155
|
+
if (!interactive) fail(`Pass --token or set ${provider.tokenEnv}.`);
|
|
1156
|
+
token = await askToken(provider, instance);
|
|
1157
|
+
}
|
|
1158
|
+
await login(provider, instance, token);
|
|
1159
|
+
p.outro(pc.green("Done"));
|
|
1160
|
+
}
|
|
1161
|
+
/** `dstack deploy logout`: remove a saved API key. */
|
|
1162
|
+
async function deployLogout(options) {
|
|
1163
|
+
const { provider, instance } = await loginTarget(options, isInteractive(false));
|
|
1164
|
+
if (deleteToken(provider.id, instance)) p.log.success(`Removed the ${provider.name} key for ${instance}.`);
|
|
1165
|
+
else p.log.info(`There was no saved ${provider.name} key for ${instance}.`);
|
|
39
1166
|
}
|
|
40
1167
|
// src/commands/doctor.ts
|
|
41
1168
|
async function doctor(directoryArgument) {
|
|
@@ -45,12 +1172,7 @@ async function doctor(directoryArgument) {
|
|
|
45
1172
|
spinner.start("Checking the project and its services");
|
|
46
1173
|
const checks = await runDoctor(directory);
|
|
47
1174
|
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
|
-
}
|
|
1175
|
+
for (const check of checks) logCheck(check);
|
|
54
1176
|
const failed = checks.filter((check) => !check.ok && !check.warning).length;
|
|
55
1177
|
if (failed) {
|
|
56
1178
|
p.outro(pc.red(`${failed} ${failed === 1 ? "check" : "checks"} failed`));
|
|
@@ -363,9 +1485,24 @@ function systemTimeZone() {
|
|
|
363
1485
|
return "UTC";
|
|
364
1486
|
}
|
|
365
1487
|
}
|
|
1488
|
+
/**
|
|
1489
|
+
* Checks Docker, Node.js and Python before the prompts. Problems only warn:
|
|
1490
|
+
* the project is created anyway, and Node.js and Python are for development
|
|
1491
|
+
* outside the containers. Returns whether Docker can run the project.
|
|
1492
|
+
*/
|
|
1493
|
+
function checkComputer(checks = checkTools()) {
|
|
1494
|
+
const passed = checks.filter((check) => check.ok);
|
|
1495
|
+
if (passed.length) p.log.success(passed.map((check) => check.detail ?? check.name).join(", "));
|
|
1496
|
+
for (const check of checks.filter((check) => !check.ok)) logCheck({
|
|
1497
|
+
...check,
|
|
1498
|
+
warning: true
|
|
1499
|
+
});
|
|
1500
|
+
return !checks.some((check) => !check.ok && !check.warning);
|
|
1501
|
+
}
|
|
366
1502
|
async function init(directoryArgument, options) {
|
|
367
1503
|
const interactive = isInteractive(options.yes);
|
|
368
1504
|
intro("Create a project");
|
|
1505
|
+
const dockerReady = checkComputer();
|
|
369
1506
|
const zoneError = options.timeZone ? validateTimeZone(options.timeZone) : void 0;
|
|
370
1507
|
if (zoneError) fail(zoneError);
|
|
371
1508
|
let directory = directoryArgument;
|
|
@@ -475,7 +1612,6 @@ async function init(directoryArgument, options) {
|
|
|
475
1612
|
} catch {
|
|
476
1613
|
p.log.warn("Could not commit the project with git. Commit it yourself.");
|
|
477
1614
|
}
|
|
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
1615
|
p.note([
|
|
480
1616
|
command(`cd ${relativeDirectory(target)}`),
|
|
481
1617
|
command("docker compose up"),
|
|
@@ -489,7 +1625,8 @@ async function init(directoryArgument, options) {
|
|
|
489
1625
|
].join("\n"), "Next steps");
|
|
490
1626
|
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
1627
|
p.log.message(`Create the same stack with ${command(`--preset ${encodePreset(stack)}`)}`);
|
|
492
|
-
if (options.start) {
|
|
1628
|
+
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")}.`));
|
|
1629
|
+
else if (options.start) {
|
|
493
1630
|
if (options.installPrompt !== false && !options.yes) await offerGlobalInstall();
|
|
494
1631
|
p.outro(`Starting the project with ${command("docker compose up")}`);
|
|
495
1632
|
execFileSync("docker", ["compose", "up"], {
|
|
@@ -605,198 +1742,6 @@ function setManifestPreset(manifestText, preset) {
|
|
|
605
1742
|
};
|
|
606
1743
|
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
607
1744
|
}
|
|
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
1745
|
// src/commands/project.ts
|
|
801
1746
|
function open(options) {
|
|
802
1747
|
if (!has("docker", ["compose", "version"])) fail("Docker Compose v2 was not found. Install Docker: https://docs.docker.com/get-docker/");
|
|
@@ -901,42 +1846,66 @@ async function manage(args, options) {
|
|
|
901
1846
|
async function shell(options) {
|
|
902
1847
|
return manage(["shell"], options);
|
|
903
1848
|
}
|
|
904
|
-
/**
|
|
1849
|
+
/**
|
|
1850
|
+
* The project and the service that run the backups: the prod worker, on a
|
|
1851
|
+
* server or the local run, or the `backup` service of projects from 0.1.
|
|
1852
|
+
*/
|
|
905
1853
|
function backupProject(options) {
|
|
906
1854
|
const project = activeProject(open(options));
|
|
907
1855
|
if (!project.prod) fail(`Backups run in the prod profile: on the server, or locally after ${command("dstack run")}.`);
|
|
908
|
-
|
|
909
|
-
|
|
1856
|
+
const running = runningServices(project);
|
|
1857
|
+
const service = ["backup", project.services.worker].find((name) => running.includes(name));
|
|
1858
|
+
if (!service) fail(`The ${project.services.worker} service is not running. Start the project with ${command("dstack run")}.`);
|
|
1859
|
+
return {
|
|
1860
|
+
project,
|
|
1861
|
+
service
|
|
1862
|
+
};
|
|
910
1863
|
}
|
|
911
|
-
/** `dstack backup`: back up the database now, or list the backups. */
|
|
1864
|
+
/** `dstack backup`: back up the database and the media now, or list the backups. */
|
|
912
1865
|
async function backup(options) {
|
|
913
|
-
|
|
1866
|
+
const { project, service } = backupProject(options);
|
|
1867
|
+
const args = [
|
|
1868
|
+
"python",
|
|
1869
|
+
"manage.py",
|
|
1870
|
+
"backup_database"
|
|
1871
|
+
];
|
|
1872
|
+
if (options.list) args.push("--list");
|
|
1873
|
+
else if (!options.media) args.push("--no-media");
|
|
1874
|
+
exitWith(compose(project, [
|
|
914
1875
|
"exec",
|
|
915
1876
|
"-T",
|
|
916
|
-
|
|
917
|
-
...
|
|
918
|
-
"python",
|
|
919
|
-
"manage.py",
|
|
920
|
-
"backup_database",
|
|
921
|
-
...options.list ? ["--list"] : []
|
|
922
|
-
]
|
|
1877
|
+
service,
|
|
1878
|
+
...args
|
|
923
1879
|
]));
|
|
924
1880
|
}
|
|
925
|
-
/**
|
|
926
|
-
|
|
927
|
-
const
|
|
928
|
-
|
|
1881
|
+
/** The management command and its options for `dstack restore`. */
|
|
1882
|
+
function restoreArgs(name, options) {
|
|
1883
|
+
const yes = options.yes ? ["--yes"] : [];
|
|
1884
|
+
if (name.endsWith(".media.tar.gz")) return [
|
|
1885
|
+
"python",
|
|
1886
|
+
"manage.py",
|
|
1887
|
+
"restore_media",
|
|
1888
|
+
name,
|
|
1889
|
+
...yes
|
|
1890
|
+
];
|
|
1891
|
+
return [
|
|
929
1892
|
"python",
|
|
930
1893
|
"manage.py",
|
|
931
1894
|
"restore_database",
|
|
932
1895
|
name,
|
|
933
|
-
...options.
|
|
1896
|
+
...options.media ? ["--media"] : [],
|
|
1897
|
+
...yes
|
|
934
1898
|
];
|
|
1899
|
+
}
|
|
1900
|
+
/** `dstack restore <backup>`: restore a database dump, with --media its media too, or a media archive. */
|
|
1901
|
+
async function restore(name, options) {
|
|
1902
|
+
const { project, service } = backupProject(options);
|
|
1903
|
+
const args = restoreArgs(name, options);
|
|
935
1904
|
if (!options.yes && !tty()) fail("Pass --yes to restore without a terminal.");
|
|
936
1905
|
exitWith(compose(project, [
|
|
937
1906
|
"exec",
|
|
938
1907
|
...options.yes ? ["-T"] : [],
|
|
939
|
-
|
|
1908
|
+
service,
|
|
940
1909
|
...args
|
|
941
1910
|
]));
|
|
942
1911
|
}
|
|
@@ -1056,13 +2025,8 @@ async function check(options) {
|
|
|
1056
2025
|
const doctorChecks = await runDoctor(project.directory);
|
|
1057
2026
|
spinner.clear();
|
|
1058
2027
|
for (const item of doctorChecks) {
|
|
1059
|
-
|
|
1060
|
-
if (item.ok
|
|
1061
|
-
else if (item.warning) p.log.warn(pc.yellow(line));
|
|
1062
|
-
else {
|
|
1063
|
-
p.log.error(pc.red(line));
|
|
1064
|
-
failed++;
|
|
1065
|
-
}
|
|
2028
|
+
logCheck(item);
|
|
2029
|
+
if (!item.ok && !item.warning) failed++;
|
|
1066
2030
|
}
|
|
1067
2031
|
}
|
|
1068
2032
|
const running = runningServices(project);
|
|
@@ -1259,9 +2223,25 @@ function createProgram() {
|
|
|
1259
2223
|
for (const flag of stackFlags()) configCommand.addOption(flag);
|
|
1260
2224
|
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
2225
|
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);
|
|
2226
|
+
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);
|
|
2227
|
+
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
2228
|
program.command("types").description("Generate the frontend's API types from the backend's schema").addOption(dirOption()).action(types);
|
|
2229
|
+
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 platform such as Coolify, and show the status");
|
|
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 the last commit is not pushed").option("-y, --yes", "Don't ask; fail when no API key is saved").action(deploy);
|
|
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);
|
|
2233
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2234
|
+
for (const flag of PROVIDERS.flatMap((provider) => provider.setupFlags)) {
|
|
2235
|
+
if (seen.has(flag.name)) continue;
|
|
2236
|
+
seen.add(flag.name);
|
|
2237
|
+
const kebab = flag.name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
|
|
2238
|
+
setupCommand.option(`--${kebab} <value>`, flag.description);
|
|
2239
|
+
}
|
|
2240
|
+
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
|
+
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);
|
|
2242
|
+
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
|
+
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
|
+
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
2245
|
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
2246
|
...options,
|
|
1267
2247
|
fromCli: (option) => cmd.getOptionValueSource(option) === "cli"
|