@django-stack/cli 0.0.1 → 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/README.md +27 -3
- package/dist/bin.js +2282 -2
- package/dist/index.d.ts +252 -1
- package/dist/index.js +1 -4
- package/dist/stack-CTyqYWCV.js +800 -0
- package/package.json +15 -7
- package/templates/backend/.dockerignore +11 -0
- package/templates/backend/Dockerfile +45 -0
- package/templates/backend/config/__init__.py +0 -0
- package/templates/backend/config/asgi.py +7 -0
- package/templates/backend/config/settings.py +87 -0
- package/templates/backend/config/studio.py +20 -0
- package/templates/backend/config/urls.py +9 -0
- package/templates/backend/config/wsgi.py +7 -0
- package/templates/backend/conftest.py +7 -0
- package/templates/backend/content/__init__.py +0 -0
- package/templates/backend/content/admin.py +107 -0
- package/templates/backend/content/apps.py +6 -0
- package/templates/backend/content/blocks.py +51 -0
- package/templates/backend/content/migrations/0001_initial.py +418 -0
- package/templates/backend/content/migrations/__init__.py +0 -0
- package/templates/backend/content/models.py +234 -0
- package/templates/backend/content/seed.py +153 -0
- package/templates/backend/content/tests/__init__.py +0 -0
- package/templates/backend/content/tests/test_api.py +73 -0
- package/templates/backend/entrypoint.sh +27 -0
- package/templates/backend/manage.py +16 -0
- package/templates/backend/pyproject.toml +33 -0
- package/templates/backend/vendor/.gitkeep +0 -0
- package/templates/base/.github/workflows/deploy.yml +70 -0
- package/templates/base/AGENTS.md +79 -0
- package/templates/base/CLAUDE.md +1 -0
- package/templates/base/Caddyfile +51 -0
- package/templates/base/README.md +87 -0
- package/templates/base/SECURITY.md +12 -0
- package/templates/base/compose.yaml +282 -0
- package/templates/base/env.example +83 -0
- package/templates/base/gitignore +15 -0
- package/templates/base/package.json +21 -0
- package/templates/frontend-astro/.dockerignore +4 -0
- package/templates/frontend-astro/Dockerfile +32 -0
- package/templates/frontend-astro/astro.config.mjs +38 -0
- package/templates/frontend-astro/package.json +31 -0
- package/templates/frontend-astro/public/favicon.svg +1 -0
- package/templates/frontend-astro/src/components/Blocks.astro +32 -0
- package/templates/frontend-astro/src/components/ContactForm.astro +68 -0
- package/templates/frontend-astro/src/components/Image.astro +34 -0
- package/templates/frontend-astro/src/components/PostCard.astro +41 -0
- package/templates/frontend-astro/src/components/PostList.astro +39 -0
- package/templates/frontend-astro/src/components/RichText.astro +14 -0
- package/templates/frontend-astro/src/components/Seo.astro +20 -0
- package/templates/frontend-astro/src/components/blocks/CallToAction.astro +23 -0
- package/templates/frontend-astro/src/components/blocks/Image.astro +24 -0
- package/templates/frontend-astro/src/components/blocks/Quote.astro +10 -0
- package/templates/frontend-astro/src/components/blocks/Text.astro +8 -0
- package/templates/frontend-astro/src/layouts/Base.astro +122 -0
- package/templates/frontend-astro/src/lib/api.ts +24 -0
- package/templates/frontend-astro/src/lib/cache.ts +58 -0
- package/templates/frontend-astro/src/lib/headless.ts +344 -0
- package/templates/frontend-astro/src/middleware.ts +69 -0
- package/templates/frontend-astro/src/pages/404.astro +13 -0
- package/templates/frontend-astro/src/pages/[...slug].astro +21 -0
- package/templates/frontend-astro/src/pages/blog/[slug].astro +50 -0
- package/templates/frontend-astro/src/pages/blog/index.astro +15 -0
- package/templates/frontend-astro/src/pages/category/[slug].astro +25 -0
- package/templates/frontend-astro/src/pages/index.astro +28 -0
- package/templates/frontend-astro/src/pages/preview/exit.ts +7 -0
- package/templates/frontend-astro/src/pages/preview/index.ts +26 -0
- package/templates/frontend-astro/src/pages/revalidate.ts +19 -0
- package/templates/frontend-astro/src/pages/robots.txt.ts +7 -0
- package/templates/frontend-astro/src/pages/rss.xml.ts +19 -0
- package/templates/frontend-astro/src/pages/sitemap.xml.ts +29 -0
- package/templates/frontend-astro/src/pages/tag/[slug].astro +24 -0
- package/templates/frontend-astro/src/styles/global.css +101 -0
- package/templates/frontend-astro/test/blocks.test.ts +42 -0
- package/templates/frontend-astro/test/cache.test.ts +22 -0
- package/templates/frontend-astro/tsconfig.json +5 -0
- package/templates/frontend-astro/vendor/.gitkeep +0 -0
- package/templates/frontend-astro/vitest.config.ts +8 -0
package/dist/bin.js
CHANGED
|
@@ -1,5 +1,2285 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
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
|
+
import { basename, delimiter, dirname, join, resolve } from "node:path";
|
|
6
|
+
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
7
|
+
import pc from "picocolors";
|
|
8
|
+
import tab from "@bomb.sh/tab/commander";
|
|
9
|
+
import * as p from "@clack/prompts";
|
|
10
|
+
import { Command, InvalidArgumentError, Option } from "commander";
|
|
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
|
+
}
|
|
740
|
+
// src/ui.ts
|
|
741
|
+
/** The DjangoStack badge with the version and an optional title. */
|
|
742
|
+
function intro(title) {
|
|
743
|
+
const badge = pc.bgGreen(pc.black(pc.bold(" DjangoStack ")));
|
|
744
|
+
p.intro(`${badge} ${pc.dim(`v${CLI_VERSION}`)}${title ? ` ${pc.bold(title)}` : ""}`);
|
|
745
|
+
}
|
|
746
|
+
function fail(message) {
|
|
747
|
+
p.cancel(pc.red(message));
|
|
748
|
+
process.exit(1);
|
|
749
|
+
}
|
|
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"));
|
|
1097
|
+
}
|
|
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;
|
|
1127
|
+
try {
|
|
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);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
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}.`);
|
|
1166
|
+
}
|
|
1167
|
+
// src/commands/doctor.ts
|
|
1168
|
+
async function doctor(directoryArgument) {
|
|
1169
|
+
const directory = resolve(directoryArgument ?? ".");
|
|
1170
|
+
intro("Doctor");
|
|
1171
|
+
const spinner = p.spinner();
|
|
1172
|
+
spinner.start("Checking the project and its services");
|
|
1173
|
+
const checks = await runDoctor(directory);
|
|
1174
|
+
spinner.clear();
|
|
1175
|
+
for (const check of checks) logCheck(check);
|
|
1176
|
+
const failed = checks.filter((check) => !check.ok && !check.warning).length;
|
|
1177
|
+
if (failed) {
|
|
1178
|
+
p.outro(pc.red(`${failed} ${failed === 1 ? "check" : "checks"} failed`));
|
|
1179
|
+
process.exitCode = 1;
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1182
|
+
p.outro(pc.green("All checks passed"));
|
|
1183
|
+
}
|
|
1184
|
+
// src/update-check.ts
|
|
1185
|
+
const PACKAGE_NAME = "@django-stack/cli";
|
|
1186
|
+
const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
1187
|
+
const DAY = 864e5;
|
|
1188
|
+
/** `$XDG_CACHE_HOME/django-stack/update-check.json`, or under `~/.cache`. */
|
|
1189
|
+
function defaultCachePath(env = process.env) {
|
|
1190
|
+
const base = env.XDG_CACHE_HOME || process.platform === "win32" && env.LOCALAPPDATA || join(homedir(), ".cache");
|
|
1191
|
+
return join(base, "django-stack", "update-check.json");
|
|
1192
|
+
}
|
|
1193
|
+
/** Whether to check at all: never in CI, without a terminal, or when turned off. */
|
|
1194
|
+
function updateCheckEnabled(env = process.env, isTTY = Boolean(process.stdout.isTTY)) {
|
|
1195
|
+
if (!isTTY) return false;
|
|
1196
|
+
if (env.CI && env.CI !== "0" && env.CI !== "false") return false;
|
|
1197
|
+
for (const key of ["DJANGO_STACK_NO_UPDATE_CHECK", "NO_UPDATE_NOTIFIER"]) if (env[key] && env[key] !== "0") return false;
|
|
1198
|
+
return true;
|
|
1199
|
+
}
|
|
1200
|
+
function parse(version) {
|
|
1201
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(version.trim());
|
|
1202
|
+
if (!match) return void 0;
|
|
1203
|
+
return [
|
|
1204
|
+
Number(match[1]),
|
|
1205
|
+
Number(match[2]),
|
|
1206
|
+
Number(match[3]),
|
|
1207
|
+
match[4] ?? ""
|
|
1208
|
+
];
|
|
1209
|
+
}
|
|
1210
|
+
/** Whether `latest` is a newer version than `current`. Prereleases of `latest` never count. */
|
|
1211
|
+
function isNewer(latest, current) {
|
|
1212
|
+
const a = parse(latest);
|
|
1213
|
+
const b = parse(current);
|
|
1214
|
+
if (!a || !b || a[3]) return false;
|
|
1215
|
+
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
|
|
1216
|
+
return b[3] !== "";
|
|
1217
|
+
}
|
|
1218
|
+
function readCache(path) {
|
|
1219
|
+
try {
|
|
1220
|
+
const data = JSON.parse(readFileSync(path, "utf8"));
|
|
1221
|
+
if (typeof data.checkedAt === "number" && typeof data.latest === "string") return data;
|
|
1222
|
+
} catch {}
|
|
1223
|
+
}
|
|
1224
|
+
function writeCache(path, cache) {
|
|
1225
|
+
try {
|
|
1226
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1227
|
+
writeFileSync(path, JSON.stringify(cache));
|
|
1228
|
+
} catch {}
|
|
1229
|
+
}
|
|
1230
|
+
/**
|
|
1231
|
+
* The latest version on npm when it is newer than `current`, otherwise
|
|
1232
|
+
* undefined. Uses a cached result for a day; never throws.
|
|
1233
|
+
*/
|
|
1234
|
+
async function checkForUpdate(options) {
|
|
1235
|
+
const cachePath = options.cachePath ?? defaultCachePath();
|
|
1236
|
+
const now = options.now ?? Date.now;
|
|
1237
|
+
const maxAge = options.maxAge ?? DAY;
|
|
1238
|
+
let latest = readCache(cachePath);
|
|
1239
|
+
if (!latest || now() - latest.checkedAt > maxAge || latest.checkedAt > now()) try {
|
|
1240
|
+
const response = await (options.fetch ?? fetch)(REGISTRY_URL, {
|
|
1241
|
+
headers: { accept: "application/json" },
|
|
1242
|
+
signal: AbortSignal.timeout(options.timeout ?? 1500)
|
|
1243
|
+
});
|
|
1244
|
+
if (!response.ok) return void 0;
|
|
1245
|
+
const { version } = await response.json();
|
|
1246
|
+
if (typeof version !== "string") return void 0;
|
|
1247
|
+
latest = {
|
|
1248
|
+
checkedAt: now(),
|
|
1249
|
+
latest: version
|
|
1250
|
+
};
|
|
1251
|
+
writeCache(cachePath, latest);
|
|
1252
|
+
} catch {
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
return isNewer(latest.latest, options.current) ? latest.latest : void 0;
|
|
1256
|
+
}
|
|
1257
|
+
/** The command that runs or installs the latest version, depending on how this one was started. */
|
|
1258
|
+
function updateCommand(location = import.meta.url, env = process.env) {
|
|
1259
|
+
const path = decodeURIComponent(location).replaceAll("\\", "/");
|
|
1260
|
+
if (path.includes("/_npx/")) return `npx ${PACKAGE_NAME}@latest`;
|
|
1261
|
+
if (path.includes("/dlx/") || path.includes("/dlx-")) return `pnpm dlx ${PACKAGE_NAME}@latest`;
|
|
1262
|
+
if (path.includes("/bunx-")) return `bunx ${PACKAGE_NAME}@latest`;
|
|
1263
|
+
if (path.includes("/pnpm/global/")) return `pnpm add -g ${PACKAGE_NAME}@latest`;
|
|
1264
|
+
if (path.includes("/.bun/install/global/")) return `bun add -g ${PACKAGE_NAME}@latest`;
|
|
1265
|
+
if (env.npm_config_user_agent?.startsWith("pnpm")) return `pnpm add -g ${PACKAGE_NAME}@latest`;
|
|
1266
|
+
return `npm install -g ${PACKAGE_NAME}@latest`;
|
|
1267
|
+
}
|
|
1268
|
+
// src/global-install.ts
|
|
1269
|
+
/** The name of the installed command. */
|
|
1270
|
+
const BIN = "dstack";
|
|
1271
|
+
const normalize = (value) => decodeURIComponent(value).replaceAll("\\", "/");
|
|
1272
|
+
/** Whether a path is in the temporary cache of npx, pnpm dlx or bunx. */
|
|
1273
|
+
function isTemporary(path) {
|
|
1274
|
+
return path.includes("/_npx/") || path.includes("/dlx/") || path.includes("/dlx-") || path.includes("/bunx-");
|
|
1275
|
+
}
|
|
1276
|
+
/**
|
|
1277
|
+
* The package manager that runs this copy from its temporary cache (npx,
|
|
1278
|
+
* pnpm dlx or bunx), or undefined for an installed copy.
|
|
1279
|
+
*/
|
|
1280
|
+
function temporaryRunner(location = import.meta.url) {
|
|
1281
|
+
const path = normalize(location);
|
|
1282
|
+
if (path.includes("/_npx/")) return "npm";
|
|
1283
|
+
if (path.includes("/dlx/") || path.includes("/dlx-")) return "pnpm";
|
|
1284
|
+
if (path.includes("/bunx-")) return "bun";
|
|
1285
|
+
}
|
|
1286
|
+
/** Whether `dstack` is on the `PATH`, leaving out the temporary directories npx and friends add. */
|
|
1287
|
+
function isOnPath(env = process.env, platform = process.platform) {
|
|
1288
|
+
const names = platform === "win32" ? [
|
|
1289
|
+
`${BIN}.cmd`,
|
|
1290
|
+
`${BIN}.exe`,
|
|
1291
|
+
`${BIN}.ps1`,
|
|
1292
|
+
BIN
|
|
1293
|
+
] : [BIN];
|
|
1294
|
+
return (env.PATH ?? env.Path ?? "").split(platform === "win32" ? ";" : delimiter).filter((dir) => dir && !isTemporary(normalize(dir)) && !normalize(dir).endsWith("node_modules/.bin")).some((dir) => names.some((name) => existsSync(join(dir, name))));
|
|
1295
|
+
}
|
|
1296
|
+
/** The command that installs the CLI globally with a package manager. */
|
|
1297
|
+
function installCommand(runner = "npm") {
|
|
1298
|
+
if (runner === "pnpm") return [
|
|
1299
|
+
"pnpm",
|
|
1300
|
+
"add",
|
|
1301
|
+
"--global",
|
|
1302
|
+
`${PACKAGE_NAME}@latest`
|
|
1303
|
+
];
|
|
1304
|
+
if (runner === "bun") return [
|
|
1305
|
+
"bun",
|
|
1306
|
+
"add",
|
|
1307
|
+
"--global",
|
|
1308
|
+
`${PACKAGE_NAME}@latest`
|
|
1309
|
+
];
|
|
1310
|
+
return [
|
|
1311
|
+
"npm",
|
|
1312
|
+
"install",
|
|
1313
|
+
"--global",
|
|
1314
|
+
`${PACKAGE_NAME}@latest`
|
|
1315
|
+
];
|
|
1316
|
+
}
|
|
1317
|
+
/** How to run the CLI from a shell: `dstack` when it is installed, otherwise through the runner. */
|
|
1318
|
+
function selfCommand(location = import.meta.url, env = process.env) {
|
|
1319
|
+
const runner = temporaryRunner(location);
|
|
1320
|
+
if (!runner || isOnPath(env)) return BIN;
|
|
1321
|
+
if (runner === "pnpm") return `pnpm dlx ${PACKAGE_NAME}`;
|
|
1322
|
+
if (runner === "bun") return `bunx ${PACKAGE_NAME}`;
|
|
1323
|
+
return `npx ${PACKAGE_NAME}`;
|
|
1324
|
+
}
|
|
1325
|
+
/** `$XDG_CONFIG_HOME/django-stack/config.json`, or under `~/.config` (`%APPDATA%` on Windows). */
|
|
1326
|
+
function defaultConfigPath(env = process.env) {
|
|
1327
|
+
const base = env.XDG_CONFIG_HOME || process.platform === "win32" && env.APPDATA || join(homedir(), ".config");
|
|
1328
|
+
return join(base, "django-stack", "config.json");
|
|
1329
|
+
}
|
|
1330
|
+
function readInstallAnswer(path = defaultConfigPath()) {
|
|
1331
|
+
try {
|
|
1332
|
+
const answer = JSON.parse(readFileSync(path, "utf8")).globalInstall;
|
|
1333
|
+
if (answer === "installed" || answer === "declined" || answer === "failed") return answer;
|
|
1334
|
+
} catch {}
|
|
1335
|
+
}
|
|
1336
|
+
function saveInstallAnswer(answer, path = defaultConfigPath()) {
|
|
1337
|
+
try {
|
|
1338
|
+
let config = {};
|
|
1339
|
+
try {
|
|
1340
|
+
config = JSON.parse(readFileSync(path, "utf8"));
|
|
1341
|
+
} catch {}
|
|
1342
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1343
|
+
writeFileSync(path, `${JSON.stringify({
|
|
1344
|
+
...config,
|
|
1345
|
+
globalInstall: answer
|
|
1346
|
+
}, null, 2)}\n`);
|
|
1347
|
+
} catch {}
|
|
1348
|
+
}
|
|
1349
|
+
/** Offer the install only once, only to npx users without `dstack`, and never in CI or scripts. */
|
|
1350
|
+
function shouldOffer({ runner, onPath, answer, interactive, env }) {
|
|
1351
|
+
if (!runner || onPath || answer || !interactive) return false;
|
|
1352
|
+
if (env.CI && env.CI !== "0" && env.CI !== "false") return false;
|
|
1353
|
+
const off = env.DJANGO_STACK_NO_INSTALL_PROMPT;
|
|
1354
|
+
return !off || off === "0";
|
|
1355
|
+
}
|
|
1356
|
+
/** A one-line reason and a fix for a failed install, from the package manager's output. */
|
|
1357
|
+
function explainFailure(runner, output) {
|
|
1358
|
+
const manual = command(installCommand(runner).join(" "));
|
|
1359
|
+
if (/ERR_PNPM_NO_GLOBAL_BIN_DIR/.test(output)) return `pnpm has no global bin directory yet. Run ${command("pnpm setup")}, open a new terminal and run ${manual}.`;
|
|
1360
|
+
if (/EACCES|EPERM|permission denied/i.test(output)) return runner === "npm" ? `No permission to write to npm's global directory. Give npm a directory in your home (https://docs.npmjs.com/resolving-eacces-permissions-errors-when-installing-packages-globally), then run ${manual}.` : `No permission to write to ${runner}'s global directory. Fix that, then run ${manual}.`;
|
|
1361
|
+
const last = output.trim().split("\n").pop()?.trim();
|
|
1362
|
+
return `${last ? `${last} ` : ""}Run ${manual} to try again.`;
|
|
1363
|
+
}
|
|
1364
|
+
function run$1(args) {
|
|
1365
|
+
return new Promise((resolve) => {
|
|
1366
|
+
const [cmd, ...rest] = args;
|
|
1367
|
+
let output = "";
|
|
1368
|
+
const child = spawn(cmd, rest, {
|
|
1369
|
+
shell: process.platform === "win32",
|
|
1370
|
+
stdio: [
|
|
1371
|
+
"ignore",
|
|
1372
|
+
"pipe",
|
|
1373
|
+
"pipe"
|
|
1374
|
+
]
|
|
1375
|
+
});
|
|
1376
|
+
child.stdout.on("data", (chunk) => output += chunk);
|
|
1377
|
+
child.stderr.on("data", (chunk) => output += chunk);
|
|
1378
|
+
child.on("error", (error) => resolve({
|
|
1379
|
+
ok: false,
|
|
1380
|
+
output: `${output}\n${error.message}`
|
|
1381
|
+
}));
|
|
1382
|
+
child.on("close", (code) => resolve({
|
|
1383
|
+
ok: code === 0,
|
|
1384
|
+
output
|
|
1385
|
+
}));
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
let offered = false;
|
|
1389
|
+
/**
|
|
1390
|
+
* Ask to install `dstack` globally when the CLI runs through npx, pnpm dlx
|
|
1391
|
+
* or bunx. Asks once per machine, remembers the answer, and never fails the
|
|
1392
|
+
* command. Returns whether it installed.
|
|
1393
|
+
*/
|
|
1394
|
+
async function offerGlobalInstall() {
|
|
1395
|
+
if (offered) return false;
|
|
1396
|
+
offered = true;
|
|
1397
|
+
const runner = temporaryRunner();
|
|
1398
|
+
const context = {
|
|
1399
|
+
runner,
|
|
1400
|
+
onPath: isOnPath(),
|
|
1401
|
+
answer: readInstallAnswer(),
|
|
1402
|
+
interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
1403
|
+
env: process.env
|
|
1404
|
+
};
|
|
1405
|
+
if (!runner || !shouldOffer(context)) return false;
|
|
1406
|
+
const args = installCommand(runner);
|
|
1407
|
+
if (await p.confirm({
|
|
1408
|
+
message: `Install ${command("dstack")} globally, so you can run ${command(`dstack <command>`)} instead of npx?`,
|
|
1409
|
+
initialValue: true
|
|
1410
|
+
}) !== true) {
|
|
1411
|
+
saveInstallAnswer("declined");
|
|
1412
|
+
p.log.message(`You won't be asked again. Install it any time with ${command(args.join(" "))}`);
|
|
1413
|
+
return false;
|
|
1414
|
+
}
|
|
1415
|
+
if (!has(runner)) {
|
|
1416
|
+
saveInstallAnswer("failed");
|
|
1417
|
+
p.log.warn(`${runner} is not on your PATH. Install ${runner}, then run ${command(args.join(" "))}`);
|
|
1418
|
+
return false;
|
|
1419
|
+
}
|
|
1420
|
+
const spinner = p.spinner();
|
|
1421
|
+
spinner.start(`Installing with ${command(args.join(" "))}`);
|
|
1422
|
+
const result = await run$1(args);
|
|
1423
|
+
if (!result.ok) {
|
|
1424
|
+
saveInstallAnswer("failed");
|
|
1425
|
+
spinner.error(`Couldn't install ${BIN}`);
|
|
1426
|
+
p.log.warn(explainFailure(runner, result.output));
|
|
1427
|
+
return false;
|
|
1428
|
+
}
|
|
1429
|
+
saveInstallAnswer("installed");
|
|
1430
|
+
spinner.stop(`Installed ${command(BIN)}`);
|
|
1431
|
+
if (!isOnPath()) p.log.warn(`${BIN} is installed, but ${runner}'s global bin directory is not on your PATH yet. ${runner === "pnpm" ? `Run ${command("pnpm setup")} and o` : "O"}pen a new terminal.`);
|
|
1432
|
+
return true;
|
|
1433
|
+
}
|
|
1434
|
+
// src/commands/init.ts
|
|
1435
|
+
/** The prompt choices of an option; values this version does not generate yet are shown but disabled. */
|
|
1436
|
+
function choices(key) {
|
|
1437
|
+
return stackOption(key).values.map((value, index) => ({
|
|
1438
|
+
value: value.value,
|
|
1439
|
+
label: value.label,
|
|
1440
|
+
hint: value.generated || index === 0 ? value.hint : "coming soon",
|
|
1441
|
+
disabled: !value.generated && index > 0
|
|
1442
|
+
}));
|
|
1443
|
+
}
|
|
1444
|
+
/**
|
|
1445
|
+
* The stack from, in order of precedence, the flags, the preset, the prompts
|
|
1446
|
+
* and the defaults. `askDatabase` asks for the Postgres version together with
|
|
1447
|
+
* the external database choice; it returns the version, or a URL.
|
|
1448
|
+
*/
|
|
1449
|
+
async function chooseStack(options, interactive, askDatabase) {
|
|
1450
|
+
let preset;
|
|
1451
|
+
if (options.preset) try {
|
|
1452
|
+
preset = resolvePreset(options.preset);
|
|
1453
|
+
} catch (error) {
|
|
1454
|
+
fail(error.message);
|
|
1455
|
+
}
|
|
1456
|
+
const stack = { ...DEFAULT_STACK };
|
|
1457
|
+
let databaseUrl;
|
|
1458
|
+
for (const option of STACK_OPTIONS) {
|
|
1459
|
+
const key = option.key;
|
|
1460
|
+
if (options.fromCli(key)) stack[key] = options[key];
|
|
1461
|
+
else if (key === "storage" && options.fromCli("localS3") && options.localS3) stack[key] = "s3";
|
|
1462
|
+
else if (preset) stack[key] = preset[key];
|
|
1463
|
+
else if (!interactive || !applies(key, stack)) continue;
|
|
1464
|
+
else if (key === "postgres" && askDatabase && !options.databaseUrl) {
|
|
1465
|
+
const answer = await askDatabase(stack[key]);
|
|
1466
|
+
if ("databaseUrl" in answer) databaseUrl = answer.databaseUrl;
|
|
1467
|
+
else stack[key] = answer.postgres;
|
|
1468
|
+
} else if (key === "postgres" && options.databaseUrl) continue;
|
|
1469
|
+
else stack[key] = unwrap(await p.select({
|
|
1470
|
+
message: option.message,
|
|
1471
|
+
options: choices(key),
|
|
1472
|
+
initialValue: stack[key]
|
|
1473
|
+
}));
|
|
1474
|
+
}
|
|
1475
|
+
return {
|
|
1476
|
+
stack,
|
|
1477
|
+
databaseUrl
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
function systemTimeZone() {
|
|
1481
|
+
try {
|
|
1482
|
+
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
1483
|
+
return zone && !validateTimeZone(zone) ? zone : "UTC";
|
|
1484
|
+
} catch {
|
|
1485
|
+
return "UTC";
|
|
1486
|
+
}
|
|
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
|
+
}
|
|
1502
|
+
async function init(directoryArgument, options) {
|
|
1503
|
+
const interactive = isInteractive(options.yes);
|
|
1504
|
+
intro("Create a project");
|
|
1505
|
+
const dockerReady = checkComputer();
|
|
1506
|
+
const zoneError = options.timeZone ? validateTimeZone(options.timeZone) : void 0;
|
|
1507
|
+
if (zoneError) fail(zoneError);
|
|
1508
|
+
let directory = directoryArgument;
|
|
1509
|
+
if (!directory) directory = interactive ? unwrap(await p.text({
|
|
1510
|
+
message: "Where should the project go?",
|
|
1511
|
+
placeholder: "my-site",
|
|
1512
|
+
defaultValue: "my-site"
|
|
1513
|
+
})) : "my-site";
|
|
1514
|
+
const target = resolve(directory);
|
|
1515
|
+
let name = options.name ?? toProjectName(basename(target));
|
|
1516
|
+
if (validateProjectName(name)) {
|
|
1517
|
+
if (!interactive) fail(`Pass a valid --name: ${validateProjectName(name)}`);
|
|
1518
|
+
name = unwrap(await p.text({
|
|
1519
|
+
message: "Project name",
|
|
1520
|
+
validate: (value) => validateProjectName(value ?? "")
|
|
1521
|
+
}));
|
|
1522
|
+
}
|
|
1523
|
+
const chosen = await chooseStack(options, interactive, async (initial) => {
|
|
1524
|
+
const choice = unwrap(await p.select({
|
|
1525
|
+
message: "Database",
|
|
1526
|
+
initialValue: initial,
|
|
1527
|
+
options: [...choices("postgres").map((value) => ({
|
|
1528
|
+
...value,
|
|
1529
|
+
label: `Bundled Postgres, ${value.label.toLowerCase()}`
|
|
1530
|
+
})), {
|
|
1531
|
+
value: "external",
|
|
1532
|
+
label: "External Postgres",
|
|
1533
|
+
hint: "a URL to your own server"
|
|
1534
|
+
}]
|
|
1535
|
+
}));
|
|
1536
|
+
if (choice !== "external") return { postgres: choice };
|
|
1537
|
+
return { databaseUrl: unwrap(await p.text({
|
|
1538
|
+
message: "Postgres URL",
|
|
1539
|
+
placeholder: "postgres://user:password@host:5432/name",
|
|
1540
|
+
validate: (value) => value?.startsWith("postgres") ? void 0 : "Enter a postgres:// URL."
|
|
1541
|
+
})) };
|
|
1542
|
+
});
|
|
1543
|
+
const stack = chosen.stack;
|
|
1544
|
+
const databaseUrl = options.databaseUrl ?? chosen.databaseUrl;
|
|
1545
|
+
const pending = pendingChoices(stack);
|
|
1546
|
+
const unavailable = pending.filter((item) => !item.isDefault);
|
|
1547
|
+
if (unavailable.length) fail(`${unavailable.map((item) => item.choice).join(", ")} ${unavailable.length > 1 ? "are" : "is"} not available yet. Leave ${unavailable.length > 1 ? "them" : "it"} at the default.`);
|
|
1548
|
+
let timeZone = options.timeZone;
|
|
1549
|
+
if (!timeZone && interactive) {
|
|
1550
|
+
const detected = systemTimeZone();
|
|
1551
|
+
timeZone = unwrap(await p.autocomplete({
|
|
1552
|
+
message: "Time zone",
|
|
1553
|
+
placeholder: "Type to filter, such as Amsterdam",
|
|
1554
|
+
initialValue: detected,
|
|
1555
|
+
maxItems: 8,
|
|
1556
|
+
options: timeZones().map((zone) => ({
|
|
1557
|
+
value: zone,
|
|
1558
|
+
label: zone,
|
|
1559
|
+
hint: zone === detected ? "this computer" : void 0
|
|
1560
|
+
}))
|
|
1561
|
+
}));
|
|
1562
|
+
}
|
|
1563
|
+
let demoContent = options.demoContent;
|
|
1564
|
+
if (interactive && !options.fromCli("demoContent")) demoContent = unwrap(await p.confirm({
|
|
1565
|
+
message: "Add demo content? (example pages and posts)",
|
|
1566
|
+
initialValue: true
|
|
1567
|
+
}));
|
|
1568
|
+
const spinner = p.spinner();
|
|
1569
|
+
spinner.start("Creating the project");
|
|
1570
|
+
let result;
|
|
1571
|
+
try {
|
|
1572
|
+
result = scaffold({
|
|
1573
|
+
directory: target,
|
|
1574
|
+
name,
|
|
1575
|
+
frontend: "astro",
|
|
1576
|
+
demoContent,
|
|
1577
|
+
databaseUrl,
|
|
1578
|
+
localS3: stack.storage === "s3",
|
|
1579
|
+
preset: encodePreset(stack),
|
|
1580
|
+
timeZone,
|
|
1581
|
+
link: options.link
|
|
1582
|
+
});
|
|
1583
|
+
} catch (error) {
|
|
1584
|
+
spinner.error("The project was not created.");
|
|
1585
|
+
fail(error.message);
|
|
1586
|
+
}
|
|
1587
|
+
spinner.stop(`Created ${pc.bold(relativeDirectory(target))} ${pc.dim(`(${result.files} files)`)}`);
|
|
1588
|
+
if (has("uv")) {
|
|
1589
|
+
spinner.start("Locking the backend dependencies");
|
|
1590
|
+
if (lockBackend(target)) spinner.stop(`Locked the backend dependencies in ${pc.bold("backend/uv.lock")}`);
|
|
1591
|
+
else spinner.error("Could not lock the backend dependencies. The image build resolves them instead.");
|
|
1592
|
+
}
|
|
1593
|
+
if (options.git && has("git")) try {
|
|
1594
|
+
execFileSync("git", ["init", "--quiet"], {
|
|
1595
|
+
cwd: target,
|
|
1596
|
+
stdio: "ignore"
|
|
1597
|
+
});
|
|
1598
|
+
execFileSync("git", ["add", "-A"], {
|
|
1599
|
+
cwd: target,
|
|
1600
|
+
stdio: "ignore"
|
|
1601
|
+
});
|
|
1602
|
+
execFileSync("git", [
|
|
1603
|
+
"commit",
|
|
1604
|
+
"--quiet",
|
|
1605
|
+
"-m",
|
|
1606
|
+
"Create project with DjangoStack"
|
|
1607
|
+
], {
|
|
1608
|
+
cwd: target,
|
|
1609
|
+
stdio: "ignore"
|
|
1610
|
+
});
|
|
1611
|
+
p.log.success("Created a git repository");
|
|
1612
|
+
} catch {
|
|
1613
|
+
p.log.warn("Could not commit the project with git. Commit it yourself.");
|
|
1614
|
+
}
|
|
1615
|
+
p.note([
|
|
1616
|
+
command(`cd ${relativeDirectory(target)}`),
|
|
1617
|
+
command("docker compose up"),
|
|
1618
|
+
"",
|
|
1619
|
+
`${pc.bold("Site")} ${url("http://localhost:3000")}`,
|
|
1620
|
+
`${pc.bold("Admin")} ${url("http://localhost:3000/admin/")}`,
|
|
1621
|
+
`${pc.bold("Mail")} ${url("http://localhost:8025")}`,
|
|
1622
|
+
"",
|
|
1623
|
+
`Admin user ${secret(result.adminUsername)}, password ${secret(result.adminPassword)}`,
|
|
1624
|
+
pc.dim("The password is in .env as ADMIN_PASSWORD. Change it after you sign in.")
|
|
1625
|
+
].join("\n"), "Next steps");
|
|
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(", ")}.`);
|
|
1627
|
+
p.log.message(`Create the same stack with ${command(`--preset ${encodePreset(stack)}`)}`);
|
|
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) {
|
|
1630
|
+
if (options.installPrompt !== false && !options.yes) await offerGlobalInstall();
|
|
1631
|
+
p.outro(`Starting the project with ${command("docker compose up")}`);
|
|
1632
|
+
execFileSync("docker", ["compose", "up"], {
|
|
1633
|
+
cwd: target,
|
|
1634
|
+
stdio: "inherit"
|
|
1635
|
+
});
|
|
1636
|
+
} else p.outro(pc.green("Done"));
|
|
1637
|
+
}
|
|
1638
|
+
// src/commands/preset.ts
|
|
1639
|
+
/** Without a code: ask for the stack options and print the code. With one: print its stack. */
|
|
1640
|
+
async function preset(code, options) {
|
|
1641
|
+
intro(code ? "Preset" : "Create a preset");
|
|
1642
|
+
if (code) {
|
|
1643
|
+
let stack;
|
|
1644
|
+
try {
|
|
1645
|
+
stack = resolvePreset(code);
|
|
1646
|
+
} catch (error) {
|
|
1647
|
+
fail(error.message);
|
|
1648
|
+
}
|
|
1649
|
+
const lines = STACK_OPTIONS.filter((option) => applies(option.key, stack)).map((option) => {
|
|
1650
|
+
const value = option.values.find((candidate) => candidate.value === stack[option.key]);
|
|
1651
|
+
return `${pc.bold(option.message.padEnd(24))}${value?.label ?? ""}`;
|
|
1652
|
+
});
|
|
1653
|
+
p.note(lines.join("\n"), command(`--preset ${encodePreset(stack)}`));
|
|
1654
|
+
p.outro(`Use it with ${command(`${selfCommand()} init --preset ${code in NAMED_PRESETS ? code : encodePreset(stack)}`)}`);
|
|
1655
|
+
return;
|
|
1656
|
+
}
|
|
1657
|
+
const { stack } = await chooseStack(options, isInteractive(options.yes));
|
|
1658
|
+
const encoded = encodePreset(stack);
|
|
1659
|
+
p.outro(`Create this stack with ${command(`${selfCommand()} init --preset ${encoded}`)}`);
|
|
1660
|
+
}
|
|
1661
|
+
// src/commands/production.ts
|
|
1662
|
+
async function production(directoryArgument, options) {
|
|
1663
|
+
const interactive = isInteractive(options.yes);
|
|
1664
|
+
const directory = resolve(directoryArgument ?? ".");
|
|
1665
|
+
const envPath = join(directory, ".env");
|
|
1666
|
+
const target = join(directory, ".env.production");
|
|
1667
|
+
intro("Production settings");
|
|
1668
|
+
if (!existsSync(envPath)) fail(`${envPath} does not exist. Run this in a project directory.`);
|
|
1669
|
+
if (existsSync(target) && !options.force) fail(`${target} exists. Pass --force to replace it.`);
|
|
1670
|
+
let domain = options.domain;
|
|
1671
|
+
if (!domain) {
|
|
1672
|
+
if (!interactive) fail("Pass --domain, such as --domain example.com.");
|
|
1673
|
+
domain = unwrap(await p.text({
|
|
1674
|
+
message: "Domain of the site",
|
|
1675
|
+
placeholder: "example.com",
|
|
1676
|
+
validate: (v) => validateDomain(v ?? "")
|
|
1677
|
+
}));
|
|
1678
|
+
}
|
|
1679
|
+
let result;
|
|
1680
|
+
try {
|
|
1681
|
+
result = productionEnv(readFileSync(envPath, "utf8"), {
|
|
1682
|
+
domain,
|
|
1683
|
+
emailUrl: options.emailUrl
|
|
1684
|
+
});
|
|
1685
|
+
} catch (error) {
|
|
1686
|
+
fail(error.message);
|
|
1687
|
+
}
|
|
1688
|
+
writeFileSync(target, result.text, { mode: 384 });
|
|
1689
|
+
p.log.success(`Wrote ${pc.bold(relativeDirectory(target))} with new secrets for the server.`);
|
|
1690
|
+
if (result.todo.length) p.note(result.todo.map((item) => `${pc.yellow("•")} ${item}`).join("\n"), "Before the first deploy");
|
|
1691
|
+
p.note([
|
|
1692
|
+
`Copy the project to the server, with ${pc.bold(".env.production")} as ${pc.bold(".env")}, then:`,
|
|
1693
|
+
command("docker compose up -d --build"),
|
|
1694
|
+
"",
|
|
1695
|
+
`${pc.bold("Admin")} ${url(`https://${domain}/admin/`)} user ${secret("admin")}, password ${secret(result.adminPassword)}`,
|
|
1696
|
+
pc.dim("Point the domain's DNS at the server first: Caddy needs it for the certificate.")
|
|
1697
|
+
].join("\n"), "Deploy");
|
|
1698
|
+
p.outro(pc.green("Done"));
|
|
1699
|
+
}
|
|
1700
|
+
// src/config.ts
|
|
1701
|
+
/** The options `config` can switch in an existing project. The rest follow with code generation. */
|
|
1702
|
+
const SWITCHABLE = ["storage"];
|
|
1703
|
+
const EMPTY_S3 = Object.fromEntries(Object.keys(LOCAL_S3).map((key) => [key, key === "S3_CREATE_BUCKET" ? "0" : ""]));
|
|
1704
|
+
/**
|
|
1705
|
+
* Applies a new stack to a project's `.env`. Throws with a one-line reason
|
|
1706
|
+
* for a change this version cannot make.
|
|
1707
|
+
*/
|
|
1708
|
+
function applyStack(envText, env, from, to) {
|
|
1709
|
+
let text = envText;
|
|
1710
|
+
const changes = [];
|
|
1711
|
+
const profiles = (env.COMPOSE_PROFILES ?? "").split(",").map((profile) => profile.trim()).filter(Boolean);
|
|
1712
|
+
for (const option of STACK_OPTIONS) {
|
|
1713
|
+
const key = option.key;
|
|
1714
|
+
if (from[key] === to[key]) continue;
|
|
1715
|
+
const value = option.values.find((candidate) => candidate.value === to[key]);
|
|
1716
|
+
if (!value) throw new Error(`${JSON.stringify(to[key])} is not a ${option.key}.`);
|
|
1717
|
+
if (!SWITCHABLE.includes(key) || !value.generated) throw new Error(`${option.flag} ${to[key]} can't be set on an existing project in this version yet.`);
|
|
1718
|
+
if (key === "storage") {
|
|
1719
|
+
if (profiles.includes("prod")) throw new Error("--storage only changes development; this .env runs the prod profile.");
|
|
1720
|
+
const endpoint = env.S3_ENDPOINT_URL ?? "";
|
|
1721
|
+
if (endpoint && !endpoint.includes("s3:8333")) throw new Error(`S3_ENDPOINT_URL points at ${endpoint}; change the S3_* values in .env yourself.`);
|
|
1722
|
+
const next = profiles.filter((profile) => profile !== "s3");
|
|
1723
|
+
if (to.storage === "s3") next.push("s3");
|
|
1724
|
+
text = setEnvValues(text, {
|
|
1725
|
+
...to.storage === "s3" ? LOCAL_S3 : EMPTY_S3,
|
|
1726
|
+
COMPOSE_PROFILES: next.join(",")
|
|
1727
|
+
});
|
|
1728
|
+
changes.push(to.storage === "s3" ? "Uploads go to the local S3 service (the s3 profile)." : "Uploads go to a Docker volume; the local S3 service is off.");
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
return {
|
|
1732
|
+
env: text,
|
|
1733
|
+
changes
|
|
1734
|
+
};
|
|
1735
|
+
}
|
|
1736
|
+
/** Sets the preset in the `django-stack` section of a project's `package.json` text. */
|
|
1737
|
+
function setManifestPreset(manifestText, preset) {
|
|
1738
|
+
const manifest = JSON.parse(manifestText);
|
|
1739
|
+
manifest["django-stack"] = {
|
|
1740
|
+
...manifest["django-stack"] ?? {},
|
|
1741
|
+
preset
|
|
1742
|
+
};
|
|
1743
|
+
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
1744
|
+
}
|
|
1745
|
+
// src/commands/project.ts
|
|
1746
|
+
function open(options) {
|
|
1747
|
+
if (!has("docker", ["compose", "version"])) fail("Docker Compose v2 was not found. Install Docker: https://docs.docker.com/get-docker/");
|
|
1748
|
+
try {
|
|
1749
|
+
return readProject(options.dir ?? ".");
|
|
1750
|
+
} catch (error) {
|
|
1751
|
+
fail(error.message);
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
function urlsNote(project, title) {
|
|
1755
|
+
const urls = projectUrls(project);
|
|
1756
|
+
const width = Math.max(...urls.map((item) => item.label.length));
|
|
1757
|
+
p.note(urls.map((item) => `${pc.bold(item.label.padEnd(width))} ${url(item.url)}`).join("\n"), title);
|
|
1758
|
+
}
|
|
1759
|
+
const tty = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
1760
|
+
/** Exit with the status of a command, but treat Ctrl+C (130) as a normal stop. */
|
|
1761
|
+
function exitWith(status) {
|
|
1762
|
+
if (status !== 0 && status !== 130) process.exitCode = status;
|
|
1763
|
+
}
|
|
1764
|
+
/** Writes `.env.run` for the local production run of a development project, once. */
|
|
1765
|
+
function ensureRunEnv(project) {
|
|
1766
|
+
const path = join(project.directory, RUN_ENV);
|
|
1767
|
+
if (!existsSync(path)) {
|
|
1768
|
+
const result = productionEnv(readFileSync(join(project.directory, ".env"), "utf8"), {
|
|
1769
|
+
domain: RUN_DOMAIN,
|
|
1770
|
+
emailUrl: "console://"
|
|
1771
|
+
});
|
|
1772
|
+
writeFileSync(path, result.text, { mode: 384 });
|
|
1773
|
+
p.log.success(`Wrote ${pc.bold(RUN_ENV)}: the prod profile at ${url(`https://${RUN_DOMAIN}`)}, with its own secrets and data.`);
|
|
1774
|
+
p.log.message(`Admin user ${secret("admin")}, password ${secret(result.adminPassword)}`);
|
|
1775
|
+
}
|
|
1776
|
+
const run = readLocalRun(project);
|
|
1777
|
+
if (!run) fail(`Could not read ${RUN_ENV}.`);
|
|
1778
|
+
return run;
|
|
1779
|
+
}
|
|
1780
|
+
/** `dstack run`: the prod profile in the background, on a server or locally at https://site.localhost. */
|
|
1781
|
+
async function run(options) {
|
|
1782
|
+
const base = open(options);
|
|
1783
|
+
intro("Run");
|
|
1784
|
+
const project = base.prod ? base : ensureRunEnv(base);
|
|
1785
|
+
const args = [
|
|
1786
|
+
"up",
|
|
1787
|
+
"--detach",
|
|
1788
|
+
"--wait",
|
|
1789
|
+
"--wait-timeout",
|
|
1790
|
+
"300"
|
|
1791
|
+
];
|
|
1792
|
+
if (options.build) args.push("--build");
|
|
1793
|
+
if (compose(project, args) !== 0) fail(`The project did not start. See ${command("dstack logs")}.`);
|
|
1794
|
+
urlsNote(project, "Running");
|
|
1795
|
+
if (project.localRun) p.log.message(pc.dim(`Caddy signs ${RUN_DOMAIN} with its own local CA, so the browser warns once. The admin password is in ${RUN_ENV}.`));
|
|
1796
|
+
p.outro(`Stop it with ${command("dstack stop")}, follow the logs with ${command("dstack logs")}`);
|
|
1797
|
+
}
|
|
1798
|
+
/** `dstack dev`: the dev profile in the foreground, with hot reload. */
|
|
1799
|
+
async function dev(options) {
|
|
1800
|
+
const project = open(options);
|
|
1801
|
+
if (project.prod) fail(`This .env runs the prod profile. Use ${command("dstack run")}.`);
|
|
1802
|
+
intro("Develop");
|
|
1803
|
+
urlsNote(project, "Starting");
|
|
1804
|
+
p.log.message(pc.dim("Changes in backend/ and frontend/ reload. Press Ctrl+C to stop."));
|
|
1805
|
+
exitWith(compose(project, ["up", ...options.build ? ["--build"] : []]));
|
|
1806
|
+
}
|
|
1807
|
+
/** `dstack stop`: stop the containers of the project and of its local run; volumes and data stay. */
|
|
1808
|
+
async function stop(options) {
|
|
1809
|
+
const project = open(options);
|
|
1810
|
+
for (const target of [project, readLocalRun(project)]) if (target && compose(target, ["down"]) !== 0) fail("The project did not stop.");
|
|
1811
|
+
}
|
|
1812
|
+
/** `dstack logs [service...]`. */
|
|
1813
|
+
async function logs(services, options) {
|
|
1814
|
+
const project = activeProject(open(options));
|
|
1815
|
+
const args = ["logs", ...options.follow ? ["--follow"] : []];
|
|
1816
|
+
if (options.tail) args.push("--tail", options.tail);
|
|
1817
|
+
exitWith(compose(project, [...args, ...services]));
|
|
1818
|
+
}
|
|
1819
|
+
/** `exec` in a running service, otherwise a one-off container. */
|
|
1820
|
+
function inService(project, service, args, interactive = tty()) {
|
|
1821
|
+
const noTty = interactive ? [] : ["-T"];
|
|
1822
|
+
if (runningServices(project).includes(service)) return [
|
|
1823
|
+
"exec",
|
|
1824
|
+
...noTty,
|
|
1825
|
+
service,
|
|
1826
|
+
...args
|
|
1827
|
+
];
|
|
1828
|
+
return [
|
|
1829
|
+
"run",
|
|
1830
|
+
"--rm",
|
|
1831
|
+
...noTty,
|
|
1832
|
+
service,
|
|
1833
|
+
...args
|
|
1834
|
+
];
|
|
1835
|
+
}
|
|
1836
|
+
/** `dstack manage <args...>`: Django's manage.py in the backend container. */
|
|
1837
|
+
async function manage(args, options) {
|
|
1838
|
+
const project = activeProject(open(options));
|
|
1839
|
+
exitWith(compose(project, inService(project, project.services.backend, [
|
|
1840
|
+
"python",
|
|
1841
|
+
"manage.py",
|
|
1842
|
+
...args
|
|
1843
|
+
])));
|
|
1844
|
+
}
|
|
1845
|
+
/** `dstack shell`: Django's shell in the backend container. */
|
|
1846
|
+
async function shell(options) {
|
|
1847
|
+
return manage(["shell"], options);
|
|
1848
|
+
}
|
|
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
|
+
*/
|
|
1853
|
+
function backupProject(options) {
|
|
1854
|
+
const project = activeProject(open(options));
|
|
1855
|
+
if (!project.prod) fail(`Backups run in the prod profile: on the server, or locally after ${command("dstack run")}.`);
|
|
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
|
+
};
|
|
1863
|
+
}
|
|
1864
|
+
/** `dstack backup`: back up the database and the media now, or list the backups. */
|
|
1865
|
+
async function backup(options) {
|
|
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, [
|
|
1875
|
+
"exec",
|
|
1876
|
+
"-T",
|
|
1877
|
+
service,
|
|
1878
|
+
...args
|
|
1879
|
+
]));
|
|
1880
|
+
}
|
|
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 [
|
|
1892
|
+
"python",
|
|
1893
|
+
"manage.py",
|
|
1894
|
+
"restore_database",
|
|
1895
|
+
name,
|
|
1896
|
+
...options.media ? ["--media"] : [],
|
|
1897
|
+
...yes
|
|
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);
|
|
1904
|
+
if (!options.yes && !tty()) fail("Pass --yes to restore without a terminal.");
|
|
1905
|
+
exitWith(compose(project, [
|
|
1906
|
+
"exec",
|
|
1907
|
+
...options.yes ? ["-T"] : [],
|
|
1908
|
+
service,
|
|
1909
|
+
...args
|
|
1910
|
+
]));
|
|
1911
|
+
}
|
|
1912
|
+
/** `dstack types`: the OpenAPI schema from the backend, and the frontend's types from it. */
|
|
1913
|
+
async function types(options) {
|
|
1914
|
+
const project = open(options);
|
|
1915
|
+
if (project.prod) fail("Generate the types in development, then commit them.");
|
|
1916
|
+
intro("Types");
|
|
1917
|
+
if (compose(project, inService(project, project.services.backend, [
|
|
1918
|
+
"python",
|
|
1919
|
+
"manage.py",
|
|
1920
|
+
"generateschema",
|
|
1921
|
+
"--generator_class",
|
|
1922
|
+
"headless.rest.schema.SchemaGenerator",
|
|
1923
|
+
"--format",
|
|
1924
|
+
"openapi-json",
|
|
1925
|
+
"--file",
|
|
1926
|
+
"openapi.json"
|
|
1927
|
+
], false)) !== 0) fail("The backend could not write its schema.");
|
|
1928
|
+
const generate = [
|
|
1929
|
+
"npx",
|
|
1930
|
+
"headless",
|
|
1931
|
+
"generate",
|
|
1932
|
+
"--file",
|
|
1933
|
+
"/schema.json",
|
|
1934
|
+
"--out",
|
|
1935
|
+
"src/lib/headless.ts"
|
|
1936
|
+
];
|
|
1937
|
+
if (compose(project, [
|
|
1938
|
+
"run",
|
|
1939
|
+
"--rm",
|
|
1940
|
+
"--no-deps",
|
|
1941
|
+
"-T",
|
|
1942
|
+
...["-v", "./backend/openapi.json:/schema.json:ro"],
|
|
1943
|
+
project.services.frontend,
|
|
1944
|
+
...generate
|
|
1945
|
+
]) !== 0) fail("The frontend could not generate the types.");
|
|
1946
|
+
p.outro(`Wrote ${pc.bold("frontend/src/lib/headless.ts")}`);
|
|
1947
|
+
}
|
|
1948
|
+
/** The code checks of `dstack check`. */
|
|
1949
|
+
function checkSteps(project, tests) {
|
|
1950
|
+
const steps = [
|
|
1951
|
+
{
|
|
1952
|
+
name: project.prod ? "Django deploy checks" : "Django system checks",
|
|
1953
|
+
service: "backend",
|
|
1954
|
+
args: project.prod ? [
|
|
1955
|
+
"python",
|
|
1956
|
+
"manage.py",
|
|
1957
|
+
"check",
|
|
1958
|
+
"--deploy",
|
|
1959
|
+
"--fail-level",
|
|
1960
|
+
"WARNING"
|
|
1961
|
+
] : [
|
|
1962
|
+
"python",
|
|
1963
|
+
"manage.py",
|
|
1964
|
+
"check"
|
|
1965
|
+
]
|
|
1966
|
+
},
|
|
1967
|
+
{
|
|
1968
|
+
name: "Migrations are up to date",
|
|
1969
|
+
service: "backend",
|
|
1970
|
+
args: [
|
|
1971
|
+
"python",
|
|
1972
|
+
"manage.py",
|
|
1973
|
+
"makemigrations",
|
|
1974
|
+
"--check",
|
|
1975
|
+
"--dry-run"
|
|
1976
|
+
]
|
|
1977
|
+
},
|
|
1978
|
+
{
|
|
1979
|
+
name: "Ruff",
|
|
1980
|
+
service: "backend",
|
|
1981
|
+
args: [
|
|
1982
|
+
"ruff",
|
|
1983
|
+
"check",
|
|
1984
|
+
"."
|
|
1985
|
+
],
|
|
1986
|
+
devOnly: true
|
|
1987
|
+
},
|
|
1988
|
+
{
|
|
1989
|
+
name: "Astro check",
|
|
1990
|
+
service: "frontend",
|
|
1991
|
+
args: [
|
|
1992
|
+
"npm",
|
|
1993
|
+
"run",
|
|
1994
|
+
"--silent",
|
|
1995
|
+
"check"
|
|
1996
|
+
],
|
|
1997
|
+
devOnly: true
|
|
1998
|
+
}
|
|
1999
|
+
];
|
|
2000
|
+
if (tests) steps.push({
|
|
2001
|
+
name: "Backend tests",
|
|
2002
|
+
service: "backend",
|
|
2003
|
+
args: ["pytest", "-q"],
|
|
2004
|
+
devOnly: true
|
|
2005
|
+
}, {
|
|
2006
|
+
name: "Frontend tests",
|
|
2007
|
+
service: "frontend",
|
|
2008
|
+
args: [
|
|
2009
|
+
"npm",
|
|
2010
|
+
"test",
|
|
2011
|
+
"--silent"
|
|
2012
|
+
],
|
|
2013
|
+
devOnly: true
|
|
2014
|
+
});
|
|
2015
|
+
return steps.filter((step) => !(step.devOnly && project.prod));
|
|
2016
|
+
}
|
|
2017
|
+
/** `dstack check`: the doctor's checks, Django's checks, the linters and, with --tests, the tests. */
|
|
2018
|
+
async function check(options) {
|
|
2019
|
+
const project = activeProject(open(options));
|
|
2020
|
+
intro("Check");
|
|
2021
|
+
let failed = 0;
|
|
2022
|
+
const spinner = p.spinner();
|
|
2023
|
+
if (!project.localRun) {
|
|
2024
|
+
spinner.start("Checking the setup");
|
|
2025
|
+
const doctorChecks = await runDoctor(project.directory);
|
|
2026
|
+
spinner.clear();
|
|
2027
|
+
for (const item of doctorChecks) {
|
|
2028
|
+
logCheck(item);
|
|
2029
|
+
if (!item.ok && !item.warning) failed++;
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
const running = runningServices(project);
|
|
2033
|
+
for (const step of checkSteps(project, Boolean(options.tests))) {
|
|
2034
|
+
const service = project.services[step.service];
|
|
2035
|
+
const args = running.includes(service) ? [
|
|
2036
|
+
"exec",
|
|
2037
|
+
"-T",
|
|
2038
|
+
service,
|
|
2039
|
+
...step.args
|
|
2040
|
+
] : [
|
|
2041
|
+
"run",
|
|
2042
|
+
"--rm",
|
|
2043
|
+
"-T",
|
|
2044
|
+
...step.service === "frontend" ? ["--no-deps"] : [],
|
|
2045
|
+
service,
|
|
2046
|
+
...step.args
|
|
2047
|
+
];
|
|
2048
|
+
spinner.start(step.name);
|
|
2049
|
+
const result = composeOutput(project, args);
|
|
2050
|
+
if (result.status === 0) {
|
|
2051
|
+
spinner.stop(step.name);
|
|
2052
|
+
continue;
|
|
2053
|
+
}
|
|
2054
|
+
failed++;
|
|
2055
|
+
spinner.error(pc.red(step.name));
|
|
2056
|
+
if (result.output) p.log.message(pc.dim(result.output.split("\n").slice(-40).join("\n")));
|
|
2057
|
+
}
|
|
2058
|
+
if (failed) {
|
|
2059
|
+
p.outro(pc.red(`${failed} ${failed === 1 ? "check" : "checks"} failed`));
|
|
2060
|
+
process.exitCode = 1;
|
|
2061
|
+
return;
|
|
2062
|
+
}
|
|
2063
|
+
p.outro(pc.green("All checks passed"));
|
|
2064
|
+
}
|
|
2065
|
+
/** The name of the frontend's node_modules volume, which hides new packages after a rebuild. */
|
|
2066
|
+
function nodeModulesVolume(project) {
|
|
2067
|
+
const result = composeOutput(project, [
|
|
2068
|
+
"config",
|
|
2069
|
+
"--format",
|
|
2070
|
+
"json"
|
|
2071
|
+
]);
|
|
2072
|
+
if (result.status !== 0) return void 0;
|
|
2073
|
+
try {
|
|
2074
|
+
return JSON.parse(result.output).volumes?.frontend_node_modules?.name;
|
|
2075
|
+
} catch {
|
|
2076
|
+
return;
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
/** `dstack update`: newer images, newer DjangoStack packages, and a restart of what was running. */
|
|
2080
|
+
async function update(options) {
|
|
2081
|
+
const project = open(options);
|
|
2082
|
+
intro("Update");
|
|
2083
|
+
const targets = [project, readLocalRun(project)].filter((target) => Boolean(target));
|
|
2084
|
+
const running = targets.filter((target) => runningServices(target).length > 0);
|
|
2085
|
+
const lock = join(project.directory, "backend", "uv.lock");
|
|
2086
|
+
if (existsSync(lock) && has("uv")) {
|
|
2087
|
+
const args = options.all ? ["lock", "--upgrade"] : [
|
|
2088
|
+
"lock",
|
|
2089
|
+
"--upgrade-package",
|
|
2090
|
+
"django-stack"
|
|
2091
|
+
];
|
|
2092
|
+
if (spawnSync("uv", args, {
|
|
2093
|
+
cwd: join(project.directory, "backend"),
|
|
2094
|
+
stdio: "inherit"
|
|
2095
|
+
}).status !== 0) fail("uv could not update backend/uv.lock.");
|
|
2096
|
+
p.log.success(options.all ? "Updated backend/uv.lock" : "Updated django-stack in backend/uv.lock");
|
|
2097
|
+
} else if (existsSync(lock)) p.log.warn("uv is not installed, so backend/uv.lock stays as it is: https://docs.astral.sh/uv/");
|
|
2098
|
+
for (const target of targets) {
|
|
2099
|
+
if (compose(target, ["pull", "--ignore-buildable"]) !== 0) fail("Pulling the images failed.");
|
|
2100
|
+
if (compose(target, [
|
|
2101
|
+
"build",
|
|
2102
|
+
"--pull",
|
|
2103
|
+
...options.cache ? [] : ["--no-cache"]
|
|
2104
|
+
]) !== 0) fail("Building the images failed.");
|
|
2105
|
+
}
|
|
2106
|
+
const volume = project.prod ? void 0 : nodeModulesVolume(project);
|
|
2107
|
+
if (volume) {
|
|
2108
|
+
compose(project, [
|
|
2109
|
+
"rm",
|
|
2110
|
+
"--stop",
|
|
2111
|
+
"--force",
|
|
2112
|
+
project.services.frontend
|
|
2113
|
+
]);
|
|
2114
|
+
spawnSync("docker", [
|
|
2115
|
+
"volume",
|
|
2116
|
+
"rm",
|
|
2117
|
+
"--force",
|
|
2118
|
+
volume
|
|
2119
|
+
], { stdio: "ignore" });
|
|
2120
|
+
}
|
|
2121
|
+
if (options.restart && running.length) {
|
|
2122
|
+
for (const target of running) if (compose(target, [
|
|
2123
|
+
"up",
|
|
2124
|
+
"--detach",
|
|
2125
|
+
"--wait",
|
|
2126
|
+
"--wait-timeout",
|
|
2127
|
+
"300"
|
|
2128
|
+
]) !== 0) fail(`The project did not start again. See ${command("dstack logs")}.`);
|
|
2129
|
+
p.outro(pc.green("Updated and restarted"));
|
|
2130
|
+
return;
|
|
2131
|
+
}
|
|
2132
|
+
p.outro(pc.green(`Updated. Start it with ${command(project.prod ? "dstack run" : "dstack dev")}`));
|
|
2133
|
+
}
|
|
2134
|
+
/** `dstack config`: change the stack options of an existing project. */
|
|
2135
|
+
async function config(options) {
|
|
2136
|
+
let project;
|
|
2137
|
+
try {
|
|
2138
|
+
project = readProject(options.dir ?? ".");
|
|
2139
|
+
} catch (error) {
|
|
2140
|
+
fail(error.message);
|
|
2141
|
+
}
|
|
2142
|
+
const interactive = isInteractive(options.yes);
|
|
2143
|
+
intro("Configure");
|
|
2144
|
+
const current = projectStack(project);
|
|
2145
|
+
const next = { ...current };
|
|
2146
|
+
for (const option of STACK_OPTIONS) {
|
|
2147
|
+
const key = option.key;
|
|
2148
|
+
if (options.fromCli(key)) next[key] = options[key];
|
|
2149
|
+
else if (interactive && SWITCHABLE.includes(key)) next[key] = unwrap(await p.select({
|
|
2150
|
+
message: option.message,
|
|
2151
|
+
initialValue: current[key],
|
|
2152
|
+
options: option.values.map((value) => ({
|
|
2153
|
+
value: value.value,
|
|
2154
|
+
label: value.label,
|
|
2155
|
+
hint: value.generated ? value.hint : "coming soon",
|
|
2156
|
+
disabled: !value.generated && value.value !== current[key]
|
|
2157
|
+
}))
|
|
2158
|
+
}));
|
|
2159
|
+
}
|
|
2160
|
+
const envPath = join(project.directory, ".env");
|
|
2161
|
+
let result;
|
|
2162
|
+
try {
|
|
2163
|
+
result = applyStack(readFileSync(envPath, "utf8"), project.env, current, next);
|
|
2164
|
+
} catch (error) {
|
|
2165
|
+
fail(error.message);
|
|
2166
|
+
}
|
|
2167
|
+
if (!result.changes.length) {
|
|
2168
|
+
p.outro("Nothing changed");
|
|
2169
|
+
return;
|
|
2170
|
+
}
|
|
2171
|
+
writeFileSync(envPath, result.env, { mode: 384 });
|
|
2172
|
+
const manifestPath = join(project.directory, "package.json");
|
|
2173
|
+
if (existsSync(manifestPath)) writeFileSync(manifestPath, setManifestPreset(readFileSync(manifestPath, "utf8"), encodePreset(next)));
|
|
2174
|
+
for (const change of result.changes) p.log.success(change);
|
|
2175
|
+
p.outro(`Restart to apply it: ${command("dstack stop")}, then ${command("dstack dev")}`);
|
|
2176
|
+
}
|
|
2177
|
+
// src/program.ts
|
|
2178
|
+
/** Commands that print machine-readable output, so no update notice. */
|
|
2179
|
+
const QUIET_COMMANDS = /* @__PURE__ */ new Set([
|
|
2180
|
+
"secret",
|
|
2181
|
+
"complete",
|
|
2182
|
+
"completion"
|
|
2183
|
+
]);
|
|
2184
|
+
/** A flag for each stack option, such as `--cache <cache>`. */
|
|
2185
|
+
function stackFlags() {
|
|
2186
|
+
return STACK_OPTIONS.map((option) => {
|
|
2187
|
+
const name = option.flag.slice(2);
|
|
2188
|
+
return new Option(`${option.flag} <${name}>`, `${option.description} (default: ${option.values[0].value})`).choices(option.values.map((value) => value.value));
|
|
2189
|
+
});
|
|
2190
|
+
}
|
|
2191
|
+
function positiveInteger(value) {
|
|
2192
|
+
const number = Number(value);
|
|
2193
|
+
if (!Number.isInteger(number) || number < 1) throw new InvalidArgumentError("Pass a whole number above 0.");
|
|
2194
|
+
return number;
|
|
2195
|
+
}
|
|
2196
|
+
function createProgram() {
|
|
2197
|
+
const program = new Command(BIN).description("Create and manage DjangoStack projects.").version(CLI_VERSION, "-v, --version", "Print the version").helpOption("-h, --help", "Show help for a command").helpCommand("help [command]", "Show help for a command").option("--no-update-check", "Don't check npm for a newer version").option("--no-install-prompt", `Don't offer to install ${BIN} globally`).showSuggestionAfterError().configureHelp({
|
|
2198
|
+
sortSubcommands: false,
|
|
2199
|
+
styleTitle: (text) => pc.bold(text),
|
|
2200
|
+
styleCommandText: (text) => pc.green(text),
|
|
2201
|
+
styleSubcommandText: (text) => pc.green(text),
|
|
2202
|
+
styleOptionText: (text) => pc.cyan(text),
|
|
2203
|
+
styleArgumentText: (text) => pc.yellow(text),
|
|
2204
|
+
styleDescriptionText: (text) => text
|
|
2205
|
+
}).configureOutput({ outputError: (text, write) => write(pc.red(text)) });
|
|
2206
|
+
const initCommand = program.command("init").alias("create").description("Create a project").argument("[directory]", "Where to create the project").option("--name <name>", "The project name (default: from the directory)").option("--preset <preset>", `A preset code from a previous init, or ${Object.keys(NAMED_PRESETS).join(", ")}`).option("--database-url <url>", "Use an external Postgres instead of the bundled one").option("--time-zone <zone>", "The time zone of the backend, such as Europe/Amsterdam (default: UTC)").addOption(new Option("--local-s3", "Same as --storage s3").hideHelp()).option("--no-demo-content", "Start with an empty site").option("--no-git", "Don't create a git repository").option("--start", "Run `docker compose up` when done").option("-y, --yes", "Accept the defaults without asking").addOption(new Option("--link <repository>", "Take the packages from a DjangoStack checkout").hideHelp()).action((directory, options, cmd) => init(directory, {
|
|
2207
|
+
...options,
|
|
2208
|
+
installPrompt: program.opts().installPrompt,
|
|
2209
|
+
fromCli: (option) => cmd.getOptionValueSource(option) === "cli"
|
|
2210
|
+
}));
|
|
2211
|
+
for (const flag of stackFlags()) initCommand.addOption(flag);
|
|
2212
|
+
const dirOption = () => new Option("-C, --dir <directory>", "The project directory (default: this one or a parent)");
|
|
2213
|
+
program.command("run").description("Run the production setup in the background (locally at https://site.localhost)").addOption(dirOption()).option("--no-build", "Don't build the images first").action(run);
|
|
2214
|
+
program.command("dev").description("Run the development servers with hot reload").addOption(dirOption()).option("--no-build", "Don't build the images first").action(dev);
|
|
2215
|
+
program.command("stop").description("Stop the project; the data stays").addOption(dirOption()).action(stop);
|
|
2216
|
+
program.command("logs").description("Show the logs of the services").argument("[services...]", "Only these services, such as backend").addOption(dirOption()).option("--no-follow", "Print the logs and exit").option("-n, --tail <lines>", "Only the last lines of each service").action(logs);
|
|
2217
|
+
program.command("check").description("Check the project: setup, Django checks, migrations and linters").addOption(dirOption()).option("--tests", "Run the backend and frontend tests too").action(check);
|
|
2218
|
+
program.command("update").description("Pull and rebuild the images and update the DjangoStack packages").addOption(dirOption()).option("--all", "Update every backend package in uv.lock, not only django-stack").option("--no-cache", "Rebuild the images from scratch").option("--no-restart", "Don't restart what was running").action(update);
|
|
2219
|
+
const configCommand = program.command("config").description("Change the stack options of a project").addOption(dirOption()).option("-y, --yes", "Don't ask; change only what the flags set").action((options, cmd) => config({
|
|
2220
|
+
...options,
|
|
2221
|
+
fromCli: (option) => cmd.getOptionValueSource(option) === "cli"
|
|
2222
|
+
}));
|
|
2223
|
+
for (const flag of stackFlags()) configCommand.addOption(flag);
|
|
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));
|
|
2225
|
+
program.command("shell").description("Open Django's shell in the backend").addOption(dirOption()).action(shell);
|
|
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);
|
|
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);
|
|
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, {
|
|
2246
|
+
...options,
|
|
2247
|
+
fromCli: (option) => cmd.getOptionValueSource(option) === "cli"
|
|
2248
|
+
}));
|
|
2249
|
+
for (const flag of stackFlags()) presetCommand.addOption(flag);
|
|
2250
|
+
program.command("production").description("Write .env.production for a server").argument("[directory]", "The project directory", ".").option("--domain <domain>", "The domain of the site, such as example.com").option("--email-url <url>", "The SMTP server, such as smtp+tls://user:pass@host:587").option("--force", "Overwrite an existing .env.production").option("-y, --yes", "Don't ask; fail when --domain is missing").action(production);
|
|
2251
|
+
program.command("doctor").description("Check a project and its services").argument("[directory]", "The project directory", ".").action(doctor);
|
|
2252
|
+
program.command("secret").description("Print a random secret").argument("[bytes]", "The number of random bytes", positiveInteger, 32).action((bytes) => console.log(generateSecret(bytes)));
|
|
2253
|
+
tab(program, { completionCommandName: "completion" });
|
|
2254
|
+
program.commands.find((cmd) => cmd.name() === "completion")?.description("Print a shell completion script (zsh, bash, fish or powershell)").addHelpText("after", `\nAdd it to your shell, for example in ~/.zshrc:\n ${pc.cyan("source <(dstack completion zsh)")}`);
|
|
2255
|
+
let update$1;
|
|
2256
|
+
let offerInstall = false;
|
|
2257
|
+
program.hook("preAction", (_root, action) => {
|
|
2258
|
+
if (QUIET_COMMANDS.has(action.name())) return;
|
|
2259
|
+
offerInstall = program.opts().installPrompt !== false && !action.opts().yes;
|
|
2260
|
+
if (program.opts().updateCheck === false || !updateCheckEnabled()) return;
|
|
2261
|
+
update$1 = checkForUpdate({ current: CLI_VERSION });
|
|
2262
|
+
});
|
|
2263
|
+
program.hook("postAction", async () => {
|
|
2264
|
+
if (offerInstall && await offerGlobalInstall()) return;
|
|
2265
|
+
const latest = await update$1;
|
|
2266
|
+
if (!latest) return;
|
|
2267
|
+
p.box([`${pc.dim(CLI_VERSION)} ${pc.dim("→")} ${pc.green(pc.bold(latest))}`, `Run ${pc.cyan(updateCommand())}`].join("\n"), pc.yellow("Update available"), {
|
|
2268
|
+
formatBorder: (text) => pc.yellow(text),
|
|
2269
|
+
contentAlign: "center",
|
|
2270
|
+
titleAlign: "center",
|
|
2271
|
+
width: "auto",
|
|
2272
|
+
rounded: true,
|
|
2273
|
+
withGuide: false
|
|
2274
|
+
});
|
|
2275
|
+
});
|
|
2276
|
+
return program;
|
|
2277
|
+
}
|
|
3
2278
|
// src/bin.ts
|
|
4
|
-
|
|
2279
|
+
const program = createProgram();
|
|
2280
|
+
if (process.argv.length <= 2) program.outputHelp();
|
|
2281
|
+
else program.parseAsync().catch((error) => {
|
|
2282
|
+
console.error(pc.red(error instanceof Error ? error.message : String(error)));
|
|
2283
|
+
process.exit(1);
|
|
2284
|
+
});
|
|
5
2285
|
export {};
|