@django-stack/cli 0.0.1 → 0.1.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.
Files changed (79) hide show
  1. package/README.md +27 -3
  2. package/dist/bin.js +1302 -2
  3. package/dist/index.d.ts +250 -1
  4. package/dist/index.js +1 -4
  5. package/dist/stack-OTrWHXrO.js +712 -0
  6. package/package.json +14 -7
  7. package/templates/backend/.dockerignore +11 -0
  8. package/templates/backend/Dockerfile +45 -0
  9. package/templates/backend/config/__init__.py +0 -0
  10. package/templates/backend/config/asgi.py +7 -0
  11. package/templates/backend/config/settings.py +87 -0
  12. package/templates/backend/config/studio.py +20 -0
  13. package/templates/backend/config/urls.py +9 -0
  14. package/templates/backend/config/wsgi.py +7 -0
  15. package/templates/backend/conftest.py +7 -0
  16. package/templates/backend/content/__init__.py +0 -0
  17. package/templates/backend/content/admin.py +107 -0
  18. package/templates/backend/content/apps.py +6 -0
  19. package/templates/backend/content/blocks.py +51 -0
  20. package/templates/backend/content/migrations/0001_initial.py +418 -0
  21. package/templates/backend/content/migrations/__init__.py +0 -0
  22. package/templates/backend/content/models.py +234 -0
  23. package/templates/backend/content/seed.py +153 -0
  24. package/templates/backend/content/tests/__init__.py +0 -0
  25. package/templates/backend/content/tests/test_api.py +73 -0
  26. package/templates/backend/entrypoint.sh +27 -0
  27. package/templates/backend/manage.py +16 -0
  28. package/templates/backend/pyproject.toml +33 -0
  29. package/templates/backend/vendor/.gitkeep +0 -0
  30. package/templates/base/.github/workflows/deploy.yml +70 -0
  31. package/templates/base/AGENTS.md +78 -0
  32. package/templates/base/CLAUDE.md +1 -0
  33. package/templates/base/Caddyfile +51 -0
  34. package/templates/base/README.md +87 -0
  35. package/templates/base/SECURITY.md +12 -0
  36. package/templates/base/compose.yaml +303 -0
  37. package/templates/base/env.example +80 -0
  38. package/templates/base/gitignore +15 -0
  39. package/templates/base/package.json +21 -0
  40. package/templates/frontend-astro/.dockerignore +4 -0
  41. package/templates/frontend-astro/Dockerfile +32 -0
  42. package/templates/frontend-astro/astro.config.mjs +38 -0
  43. package/templates/frontend-astro/package.json +31 -0
  44. package/templates/frontend-astro/public/favicon.svg +1 -0
  45. package/templates/frontend-astro/src/components/Blocks.astro +32 -0
  46. package/templates/frontend-astro/src/components/ContactForm.astro +68 -0
  47. package/templates/frontend-astro/src/components/Image.astro +34 -0
  48. package/templates/frontend-astro/src/components/PostCard.astro +41 -0
  49. package/templates/frontend-astro/src/components/PostList.astro +39 -0
  50. package/templates/frontend-astro/src/components/RichText.astro +14 -0
  51. package/templates/frontend-astro/src/components/Seo.astro +20 -0
  52. package/templates/frontend-astro/src/components/blocks/CallToAction.astro +23 -0
  53. package/templates/frontend-astro/src/components/blocks/Image.astro +24 -0
  54. package/templates/frontend-astro/src/components/blocks/Quote.astro +10 -0
  55. package/templates/frontend-astro/src/components/blocks/Text.astro +8 -0
  56. package/templates/frontend-astro/src/layouts/Base.astro +122 -0
  57. package/templates/frontend-astro/src/lib/api.ts +24 -0
  58. package/templates/frontend-astro/src/lib/cache.ts +58 -0
  59. package/templates/frontend-astro/src/lib/headless.ts +344 -0
  60. package/templates/frontend-astro/src/middleware.ts +69 -0
  61. package/templates/frontend-astro/src/pages/404.astro +13 -0
  62. package/templates/frontend-astro/src/pages/[...slug].astro +21 -0
  63. package/templates/frontend-astro/src/pages/blog/[slug].astro +50 -0
  64. package/templates/frontend-astro/src/pages/blog/index.astro +15 -0
  65. package/templates/frontend-astro/src/pages/category/[slug].astro +25 -0
  66. package/templates/frontend-astro/src/pages/index.astro +28 -0
  67. package/templates/frontend-astro/src/pages/preview/exit.ts +7 -0
  68. package/templates/frontend-astro/src/pages/preview/index.ts +26 -0
  69. package/templates/frontend-astro/src/pages/revalidate.ts +19 -0
  70. package/templates/frontend-astro/src/pages/robots.txt.ts +7 -0
  71. package/templates/frontend-astro/src/pages/rss.xml.ts +19 -0
  72. package/templates/frontend-astro/src/pages/sitemap.xml.ts +29 -0
  73. package/templates/frontend-astro/src/pages/tag/[slug].astro +24 -0
  74. package/templates/frontend-astro/src/styles/global.css +101 -0
  75. package/templates/frontend-astro/test/blocks.test.ts +42 -0
  76. package/templates/frontend-astro/test/cache.test.ts +22 -0
  77. package/templates/frontend-astro/tsconfig.json +5 -0
  78. package/templates/frontend-astro/vendor/.gitkeep +0 -0
  79. package/templates/frontend-astro/vitest.config.ts +8 -0
package/dist/bin.js CHANGED
@@ -1,5 +1,1305 @@
1
1
  #!/usr/bin/env node
2
- import { VERSION } from "./index.js";
2
+ import { D as setEnvValues, E as parseEnv, S as validateDomain, T as runDoctor, _ as toProjectName, a as decodePreset, b as CLI_VERSION, c as resolvePreset, d as LOCAL_S3, f as lockBackend, g as timeZones, h as scaffold, i as applies, l as stackOption, m as relativeDirectory, n as NAMED_PRESETS, o as encodePreset, r as STACK_OPTIONS, s as pendingChoices, t as DEFAULT_STACK, v as validateProjectName, w as generateSecret, x as productionEnv, y as validateTimeZone } from "./stack-OTrWHXrO.js";
3
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { basename, delimiter, dirname, join, resolve } from "node:path";
6
+ import pc from "picocolors";
7
+ import tab from "@bomb.sh/tab/commander";
8
+ import * as p from "@clack/prompts";
9
+ import { Command, InvalidArgumentError, Option } from "commander";
10
+ import { homedir } from "node:os";
11
+ // src/ui.ts
12
+ /** The DjangoStack badge with the version and an optional title. */
13
+ function intro(title) {
14
+ const badge = pc.bgGreen(pc.black(pc.bold(" DjangoStack ")));
15
+ p.intro(`${badge} ${pc.dim(`v${CLI_VERSION}`)}${title ? ` ${pc.bold(title)}` : ""}`);
16
+ }
17
+ function fail(message) {
18
+ p.cancel(pc.red(message));
19
+ process.exit(1);
20
+ }
21
+ function unwrap(value) {
22
+ if (p.isCancel(value)) fail("Cancelled.");
23
+ return value;
24
+ }
25
+ function has(command, args = ["--version"]) {
26
+ try {
27
+ execFileSync(command, args, { stdio: "ignore" });
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+ const url = (value) => pc.cyan(pc.underline(value));
34
+ const command = (value) => pc.cyan(value);
35
+ const secret = (value) => pc.bold(pc.yellow(value));
36
+ /** Prompts only when stdin is a terminal and `--yes` was not passed. */
37
+ function isInteractive(yes) {
38
+ return !yes && Boolean(process.stdin.isTTY);
39
+ }
40
+ // src/commands/doctor.ts
41
+ async function doctor(directoryArgument) {
42
+ const directory = resolve(directoryArgument ?? ".");
43
+ intro("Doctor");
44
+ const spinner = p.spinner();
45
+ spinner.start("Checking the project and its services");
46
+ const checks = await runDoctor(directory);
47
+ spinner.clear();
48
+ for (const check of checks) {
49
+ const line = check.detail ? `${check.name} ${pc.dim(`- ${check.detail}`)}` : check.name;
50
+ if (check.ok) p.log.success(line);
51
+ else if (check.warning) p.log.warn(pc.yellow(line));
52
+ else p.log.error(pc.red(line));
53
+ }
54
+ const failed = checks.filter((check) => !check.ok && !check.warning).length;
55
+ if (failed) {
56
+ p.outro(pc.red(`${failed} ${failed === 1 ? "check" : "checks"} failed`));
57
+ process.exitCode = 1;
58
+ return;
59
+ }
60
+ p.outro(pc.green("All checks passed"));
61
+ }
62
+ // src/update-check.ts
63
+ const PACKAGE_NAME = "@django-stack/cli";
64
+ const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
65
+ const DAY = 864e5;
66
+ /** `$XDG_CACHE_HOME/django-stack/update-check.json`, or under `~/.cache`. */
67
+ function defaultCachePath(env = process.env) {
68
+ const base = env.XDG_CACHE_HOME || process.platform === "win32" && env.LOCALAPPDATA || join(homedir(), ".cache");
69
+ return join(base, "django-stack", "update-check.json");
70
+ }
71
+ /** Whether to check at all: never in CI, without a terminal, or when turned off. */
72
+ function updateCheckEnabled(env = process.env, isTTY = Boolean(process.stdout.isTTY)) {
73
+ if (!isTTY) return false;
74
+ if (env.CI && env.CI !== "0" && env.CI !== "false") return false;
75
+ for (const key of ["DJANGO_STACK_NO_UPDATE_CHECK", "NO_UPDATE_NOTIFIER"]) if (env[key] && env[key] !== "0") return false;
76
+ return true;
77
+ }
78
+ function parse(version) {
79
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(version.trim());
80
+ if (!match) return void 0;
81
+ return [
82
+ Number(match[1]),
83
+ Number(match[2]),
84
+ Number(match[3]),
85
+ match[4] ?? ""
86
+ ];
87
+ }
88
+ /** Whether `latest` is a newer version than `current`. Prereleases of `latest` never count. */
89
+ function isNewer(latest, current) {
90
+ const a = parse(latest);
91
+ const b = parse(current);
92
+ if (!a || !b || a[3]) return false;
93
+ for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
94
+ return b[3] !== "";
95
+ }
96
+ function readCache(path) {
97
+ try {
98
+ const data = JSON.parse(readFileSync(path, "utf8"));
99
+ if (typeof data.checkedAt === "number" && typeof data.latest === "string") return data;
100
+ } catch {}
101
+ }
102
+ function writeCache(path, cache) {
103
+ try {
104
+ mkdirSync(dirname(path), { recursive: true });
105
+ writeFileSync(path, JSON.stringify(cache));
106
+ } catch {}
107
+ }
108
+ /**
109
+ * The latest version on npm when it is newer than `current`, otherwise
110
+ * undefined. Uses a cached result for a day; never throws.
111
+ */
112
+ async function checkForUpdate(options) {
113
+ const cachePath = options.cachePath ?? defaultCachePath();
114
+ const now = options.now ?? Date.now;
115
+ const maxAge = options.maxAge ?? DAY;
116
+ let latest = readCache(cachePath);
117
+ if (!latest || now() - latest.checkedAt > maxAge || latest.checkedAt > now()) try {
118
+ const response = await (options.fetch ?? fetch)(REGISTRY_URL, {
119
+ headers: { accept: "application/json" },
120
+ signal: AbortSignal.timeout(options.timeout ?? 1500)
121
+ });
122
+ if (!response.ok) return void 0;
123
+ const { version } = await response.json();
124
+ if (typeof version !== "string") return void 0;
125
+ latest = {
126
+ checkedAt: now(),
127
+ latest: version
128
+ };
129
+ writeCache(cachePath, latest);
130
+ } catch {
131
+ return;
132
+ }
133
+ return isNewer(latest.latest, options.current) ? latest.latest : void 0;
134
+ }
135
+ /** The command that runs or installs the latest version, depending on how this one was started. */
136
+ function updateCommand(location = import.meta.url, env = process.env) {
137
+ const path = decodeURIComponent(location).replaceAll("\\", "/");
138
+ if (path.includes("/_npx/")) return `npx ${PACKAGE_NAME}@latest`;
139
+ if (path.includes("/dlx/") || path.includes("/dlx-")) return `pnpm dlx ${PACKAGE_NAME}@latest`;
140
+ if (path.includes("/bunx-")) return `bunx ${PACKAGE_NAME}@latest`;
141
+ if (path.includes("/pnpm/global/")) return `pnpm add -g ${PACKAGE_NAME}@latest`;
142
+ if (path.includes("/.bun/install/global/")) return `bun add -g ${PACKAGE_NAME}@latest`;
143
+ if (env.npm_config_user_agent?.startsWith("pnpm")) return `pnpm add -g ${PACKAGE_NAME}@latest`;
144
+ return `npm install -g ${PACKAGE_NAME}@latest`;
145
+ }
146
+ // src/global-install.ts
147
+ /** The name of the installed command. */
148
+ const BIN = "dstack";
149
+ const normalize = (value) => decodeURIComponent(value).replaceAll("\\", "/");
150
+ /** Whether a path is in the temporary cache of npx, pnpm dlx or bunx. */
151
+ function isTemporary(path) {
152
+ return path.includes("/_npx/") || path.includes("/dlx/") || path.includes("/dlx-") || path.includes("/bunx-");
153
+ }
154
+ /**
155
+ * The package manager that runs this copy from its temporary cache (npx,
156
+ * pnpm dlx or bunx), or undefined for an installed copy.
157
+ */
158
+ function temporaryRunner(location = import.meta.url) {
159
+ const path = normalize(location);
160
+ if (path.includes("/_npx/")) return "npm";
161
+ if (path.includes("/dlx/") || path.includes("/dlx-")) return "pnpm";
162
+ if (path.includes("/bunx-")) return "bun";
163
+ }
164
+ /** Whether `dstack` is on the `PATH`, leaving out the temporary directories npx and friends add. */
165
+ function isOnPath(env = process.env, platform = process.platform) {
166
+ const names = platform === "win32" ? [
167
+ `${BIN}.cmd`,
168
+ `${BIN}.exe`,
169
+ `${BIN}.ps1`,
170
+ BIN
171
+ ] : [BIN];
172
+ 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))));
173
+ }
174
+ /** The command that installs the CLI globally with a package manager. */
175
+ function installCommand(runner = "npm") {
176
+ if (runner === "pnpm") return [
177
+ "pnpm",
178
+ "add",
179
+ "--global",
180
+ `${PACKAGE_NAME}@latest`
181
+ ];
182
+ if (runner === "bun") return [
183
+ "bun",
184
+ "add",
185
+ "--global",
186
+ `${PACKAGE_NAME}@latest`
187
+ ];
188
+ return [
189
+ "npm",
190
+ "install",
191
+ "--global",
192
+ `${PACKAGE_NAME}@latest`
193
+ ];
194
+ }
195
+ /** How to run the CLI from a shell: `dstack` when it is installed, otherwise through the runner. */
196
+ function selfCommand(location = import.meta.url, env = process.env) {
197
+ const runner = temporaryRunner(location);
198
+ if (!runner || isOnPath(env)) return BIN;
199
+ if (runner === "pnpm") return `pnpm dlx ${PACKAGE_NAME}`;
200
+ if (runner === "bun") return `bunx ${PACKAGE_NAME}`;
201
+ return `npx ${PACKAGE_NAME}`;
202
+ }
203
+ /** `$XDG_CONFIG_HOME/django-stack/config.json`, or under `~/.config` (`%APPDATA%` on Windows). */
204
+ function defaultConfigPath(env = process.env) {
205
+ const base = env.XDG_CONFIG_HOME || process.platform === "win32" && env.APPDATA || join(homedir(), ".config");
206
+ return join(base, "django-stack", "config.json");
207
+ }
208
+ function readInstallAnswer(path = defaultConfigPath()) {
209
+ try {
210
+ const answer = JSON.parse(readFileSync(path, "utf8")).globalInstall;
211
+ if (answer === "installed" || answer === "declined" || answer === "failed") return answer;
212
+ } catch {}
213
+ }
214
+ function saveInstallAnswer(answer, path = defaultConfigPath()) {
215
+ try {
216
+ let config = {};
217
+ try {
218
+ config = JSON.parse(readFileSync(path, "utf8"));
219
+ } catch {}
220
+ mkdirSync(dirname(path), { recursive: true });
221
+ writeFileSync(path, `${JSON.stringify({
222
+ ...config,
223
+ globalInstall: answer
224
+ }, null, 2)}\n`);
225
+ } catch {}
226
+ }
227
+ /** Offer the install only once, only to npx users without `dstack`, and never in CI or scripts. */
228
+ function shouldOffer({ runner, onPath, answer, interactive, env }) {
229
+ if (!runner || onPath || answer || !interactive) return false;
230
+ if (env.CI && env.CI !== "0" && env.CI !== "false") return false;
231
+ const off = env.DJANGO_STACK_NO_INSTALL_PROMPT;
232
+ return !off || off === "0";
233
+ }
234
+ /** A one-line reason and a fix for a failed install, from the package manager's output. */
235
+ function explainFailure(runner, output) {
236
+ const manual = command(installCommand(runner).join(" "));
237
+ 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}.`;
238
+ 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}.`;
239
+ const last = output.trim().split("\n").pop()?.trim();
240
+ return `${last ? `${last} ` : ""}Run ${manual} to try again.`;
241
+ }
242
+ function run$1(args) {
243
+ return new Promise((resolve) => {
244
+ const [cmd, ...rest] = args;
245
+ let output = "";
246
+ const child = spawn(cmd, rest, {
247
+ shell: process.platform === "win32",
248
+ stdio: [
249
+ "ignore",
250
+ "pipe",
251
+ "pipe"
252
+ ]
253
+ });
254
+ child.stdout.on("data", (chunk) => output += chunk);
255
+ child.stderr.on("data", (chunk) => output += chunk);
256
+ child.on("error", (error) => resolve({
257
+ ok: false,
258
+ output: `${output}\n${error.message}`
259
+ }));
260
+ child.on("close", (code) => resolve({
261
+ ok: code === 0,
262
+ output
263
+ }));
264
+ });
265
+ }
266
+ let offered = false;
267
+ /**
268
+ * Ask to install `dstack` globally when the CLI runs through npx, pnpm dlx
269
+ * or bunx. Asks once per machine, remembers the answer, and never fails the
270
+ * command. Returns whether it installed.
271
+ */
272
+ async function offerGlobalInstall() {
273
+ if (offered) return false;
274
+ offered = true;
275
+ const runner = temporaryRunner();
276
+ const context = {
277
+ runner,
278
+ onPath: isOnPath(),
279
+ answer: readInstallAnswer(),
280
+ interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
281
+ env: process.env
282
+ };
283
+ if (!runner || !shouldOffer(context)) return false;
284
+ const args = installCommand(runner);
285
+ if (await p.confirm({
286
+ message: `Install ${command("dstack")} globally, so you can run ${command(`dstack <command>`)} instead of npx?`,
287
+ initialValue: true
288
+ }) !== true) {
289
+ saveInstallAnswer("declined");
290
+ p.log.message(`You won't be asked again. Install it any time with ${command(args.join(" "))}`);
291
+ return false;
292
+ }
293
+ if (!has(runner)) {
294
+ saveInstallAnswer("failed");
295
+ p.log.warn(`${runner} is not on your PATH. Install ${runner}, then run ${command(args.join(" "))}`);
296
+ return false;
297
+ }
298
+ const spinner = p.spinner();
299
+ spinner.start(`Installing with ${command(args.join(" "))}`);
300
+ const result = await run$1(args);
301
+ if (!result.ok) {
302
+ saveInstallAnswer("failed");
303
+ spinner.error(`Couldn't install ${BIN}`);
304
+ p.log.warn(explainFailure(runner, result.output));
305
+ return false;
306
+ }
307
+ saveInstallAnswer("installed");
308
+ spinner.stop(`Installed ${command(BIN)}`);
309
+ 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.`);
310
+ return true;
311
+ }
312
+ // src/commands/init.ts
313
+ /** The prompt choices of an option; values this version does not generate yet are shown but disabled. */
314
+ function choices(key) {
315
+ return stackOption(key).values.map((value, index) => ({
316
+ value: value.value,
317
+ label: value.label,
318
+ hint: value.generated || index === 0 ? value.hint : "coming soon",
319
+ disabled: !value.generated && index > 0
320
+ }));
321
+ }
322
+ /**
323
+ * The stack from, in order of precedence, the flags, the preset, the prompts
324
+ * and the defaults. `askDatabase` asks for the Postgres version together with
325
+ * the external database choice; it returns the version, or a URL.
326
+ */
327
+ async function chooseStack(options, interactive, askDatabase) {
328
+ let preset;
329
+ if (options.preset) try {
330
+ preset = resolvePreset(options.preset);
331
+ } catch (error) {
332
+ fail(error.message);
333
+ }
334
+ const stack = { ...DEFAULT_STACK };
335
+ let databaseUrl;
336
+ for (const option of STACK_OPTIONS) {
337
+ const key = option.key;
338
+ if (options.fromCli(key)) stack[key] = options[key];
339
+ else if (key === "storage" && options.fromCli("localS3") && options.localS3) stack[key] = "s3";
340
+ else if (preset) stack[key] = preset[key];
341
+ else if (!interactive || !applies(key, stack)) continue;
342
+ else if (key === "postgres" && askDatabase && !options.databaseUrl) {
343
+ const answer = await askDatabase(stack[key]);
344
+ if ("databaseUrl" in answer) databaseUrl = answer.databaseUrl;
345
+ else stack[key] = answer.postgres;
346
+ } else if (key === "postgres" && options.databaseUrl) continue;
347
+ else stack[key] = unwrap(await p.select({
348
+ message: option.message,
349
+ options: choices(key),
350
+ initialValue: stack[key]
351
+ }));
352
+ }
353
+ return {
354
+ stack,
355
+ databaseUrl
356
+ };
357
+ }
358
+ function systemTimeZone() {
359
+ try {
360
+ const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
361
+ return zone && !validateTimeZone(zone) ? zone : "UTC";
362
+ } catch {
363
+ return "UTC";
364
+ }
365
+ }
366
+ async function init(directoryArgument, options) {
367
+ const interactive = isInteractive(options.yes);
368
+ intro("Create a project");
369
+ const zoneError = options.timeZone ? validateTimeZone(options.timeZone) : void 0;
370
+ if (zoneError) fail(zoneError);
371
+ let directory = directoryArgument;
372
+ if (!directory) directory = interactive ? unwrap(await p.text({
373
+ message: "Where should the project go?",
374
+ placeholder: "my-site",
375
+ defaultValue: "my-site"
376
+ })) : "my-site";
377
+ const target = resolve(directory);
378
+ let name = options.name ?? toProjectName(basename(target));
379
+ if (validateProjectName(name)) {
380
+ if (!interactive) fail(`Pass a valid --name: ${validateProjectName(name)}`);
381
+ name = unwrap(await p.text({
382
+ message: "Project name",
383
+ validate: (value) => validateProjectName(value ?? "")
384
+ }));
385
+ }
386
+ const chosen = await chooseStack(options, interactive, async (initial) => {
387
+ const choice = unwrap(await p.select({
388
+ message: "Database",
389
+ initialValue: initial,
390
+ options: [...choices("postgres").map((value) => ({
391
+ ...value,
392
+ label: `Bundled Postgres, ${value.label.toLowerCase()}`
393
+ })), {
394
+ value: "external",
395
+ label: "External Postgres",
396
+ hint: "a URL to your own server"
397
+ }]
398
+ }));
399
+ if (choice !== "external") return { postgres: choice };
400
+ return { databaseUrl: unwrap(await p.text({
401
+ message: "Postgres URL",
402
+ placeholder: "postgres://user:password@host:5432/name",
403
+ validate: (value) => value?.startsWith("postgres") ? void 0 : "Enter a postgres:// URL."
404
+ })) };
405
+ });
406
+ const stack = chosen.stack;
407
+ const databaseUrl = options.databaseUrl ?? chosen.databaseUrl;
408
+ const pending = pendingChoices(stack);
409
+ const unavailable = pending.filter((item) => !item.isDefault);
410
+ 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.`);
411
+ let timeZone = options.timeZone;
412
+ if (!timeZone && interactive) {
413
+ const detected = systemTimeZone();
414
+ timeZone = unwrap(await p.autocomplete({
415
+ message: "Time zone",
416
+ placeholder: "Type to filter, such as Amsterdam",
417
+ initialValue: detected,
418
+ maxItems: 8,
419
+ options: timeZones().map((zone) => ({
420
+ value: zone,
421
+ label: zone,
422
+ hint: zone === detected ? "this computer" : void 0
423
+ }))
424
+ }));
425
+ }
426
+ let demoContent = options.demoContent;
427
+ if (interactive && !options.fromCli("demoContent")) demoContent = unwrap(await p.confirm({
428
+ message: "Add demo content? (example pages and posts)",
429
+ initialValue: true
430
+ }));
431
+ const spinner = p.spinner();
432
+ spinner.start("Creating the project");
433
+ let result;
434
+ try {
435
+ result = scaffold({
436
+ directory: target,
437
+ name,
438
+ frontend: "astro",
439
+ demoContent,
440
+ databaseUrl,
441
+ localS3: stack.storage === "s3",
442
+ preset: encodePreset(stack),
443
+ timeZone,
444
+ link: options.link
445
+ });
446
+ } catch (error) {
447
+ spinner.error("The project was not created.");
448
+ fail(error.message);
449
+ }
450
+ spinner.stop(`Created ${pc.bold(relativeDirectory(target))} ${pc.dim(`(${result.files} files)`)}`);
451
+ if (has("uv")) {
452
+ spinner.start("Locking the backend dependencies");
453
+ if (lockBackend(target)) spinner.stop(`Locked the backend dependencies in ${pc.bold("backend/uv.lock")}`);
454
+ else spinner.error("Could not lock the backend dependencies. The image build resolves them instead.");
455
+ }
456
+ if (options.git && has("git")) try {
457
+ execFileSync("git", ["init", "--quiet"], {
458
+ cwd: target,
459
+ stdio: "ignore"
460
+ });
461
+ execFileSync("git", ["add", "-A"], {
462
+ cwd: target,
463
+ stdio: "ignore"
464
+ });
465
+ execFileSync("git", [
466
+ "commit",
467
+ "--quiet",
468
+ "-m",
469
+ "Create project with DjangoStack"
470
+ ], {
471
+ cwd: target,
472
+ stdio: "ignore"
473
+ });
474
+ p.log.success("Created a git repository");
475
+ } catch {
476
+ p.log.warn("Could not commit the project with git. Commit it yourself.");
477
+ }
478
+ if (!has("docker", ["compose", "version"])) p.log.warn(`Docker Compose was not found. Install Docker to run the project: ${url("https://docs.docker.com/get-docker/")}`);
479
+ p.note([
480
+ command(`cd ${relativeDirectory(target)}`),
481
+ command("docker compose up"),
482
+ "",
483
+ `${pc.bold("Site")} ${url("http://localhost:3000")}`,
484
+ `${pc.bold("Admin")} ${url("http://localhost:3000/admin/")}`,
485
+ `${pc.bold("Mail")} ${url("http://localhost:8025")}`,
486
+ "",
487
+ `Admin user ${secret(result.adminUsername)}, password ${secret(result.adminPassword)}`,
488
+ pc.dim("The password is in .env as ADMIN_PASSWORD. Change it after you sign in.")
489
+ ].join("\n"), "Next steps");
490
+ if (pending.length) p.log.info(`Coming in a later version, so not in this project yet: ${pending.map((item) => `${stackOption(item.key).message.toLowerCase()} (${stack[item.key]})`).join(", ")}.`);
491
+ p.log.message(`Create the same stack with ${command(`--preset ${encodePreset(stack)}`)}`);
492
+ if (options.start) {
493
+ if (options.installPrompt !== false && !options.yes) await offerGlobalInstall();
494
+ p.outro(`Starting the project with ${command("docker compose up")}`);
495
+ execFileSync("docker", ["compose", "up"], {
496
+ cwd: target,
497
+ stdio: "inherit"
498
+ });
499
+ } else p.outro(pc.green("Done"));
500
+ }
501
+ // src/commands/preset.ts
502
+ /** Without a code: ask for the stack options and print the code. With one: print its stack. */
503
+ async function preset(code, options) {
504
+ intro(code ? "Preset" : "Create a preset");
505
+ if (code) {
506
+ let stack;
507
+ try {
508
+ stack = resolvePreset(code);
509
+ } catch (error) {
510
+ fail(error.message);
511
+ }
512
+ const lines = STACK_OPTIONS.filter((option) => applies(option.key, stack)).map((option) => {
513
+ const value = option.values.find((candidate) => candidate.value === stack[option.key]);
514
+ return `${pc.bold(option.message.padEnd(24))}${value?.label ?? ""}`;
515
+ });
516
+ p.note(lines.join("\n"), command(`--preset ${encodePreset(stack)}`));
517
+ p.outro(`Use it with ${command(`${selfCommand()} init --preset ${code in NAMED_PRESETS ? code : encodePreset(stack)}`)}`);
518
+ return;
519
+ }
520
+ const { stack } = await chooseStack(options, isInteractive(options.yes));
521
+ const encoded = encodePreset(stack);
522
+ p.outro(`Create this stack with ${command(`${selfCommand()} init --preset ${encoded}`)}`);
523
+ }
524
+ // src/commands/production.ts
525
+ async function production(directoryArgument, options) {
526
+ const interactive = isInteractive(options.yes);
527
+ const directory = resolve(directoryArgument ?? ".");
528
+ const envPath = join(directory, ".env");
529
+ const target = join(directory, ".env.production");
530
+ intro("Production settings");
531
+ if (!existsSync(envPath)) fail(`${envPath} does not exist. Run this in a project directory.`);
532
+ if (existsSync(target) && !options.force) fail(`${target} exists. Pass --force to replace it.`);
533
+ let domain = options.domain;
534
+ if (!domain) {
535
+ if (!interactive) fail("Pass --domain, such as --domain example.com.");
536
+ domain = unwrap(await p.text({
537
+ message: "Domain of the site",
538
+ placeholder: "example.com",
539
+ validate: (v) => validateDomain(v ?? "")
540
+ }));
541
+ }
542
+ let result;
543
+ try {
544
+ result = productionEnv(readFileSync(envPath, "utf8"), {
545
+ domain,
546
+ emailUrl: options.emailUrl
547
+ });
548
+ } catch (error) {
549
+ fail(error.message);
550
+ }
551
+ writeFileSync(target, result.text, { mode: 384 });
552
+ p.log.success(`Wrote ${pc.bold(relativeDirectory(target))} with new secrets for the server.`);
553
+ if (result.todo.length) p.note(result.todo.map((item) => `${pc.yellow("•")} ${item}`).join("\n"), "Before the first deploy");
554
+ p.note([
555
+ `Copy the project to the server, with ${pc.bold(".env.production")} as ${pc.bold(".env")}, then:`,
556
+ command("docker compose up -d --build"),
557
+ "",
558
+ `${pc.bold("Admin")} ${url(`https://${domain}/admin/`)} user ${secret("admin")}, password ${secret(result.adminPassword)}`,
559
+ pc.dim("Point the domain's DNS at the server first: Caddy needs it for the certificate.")
560
+ ].join("\n"), "Deploy");
561
+ p.outro(pc.green("Done"));
562
+ }
563
+ // src/config.ts
564
+ /** The options `config` can switch in an existing project. The rest follow with code generation. */
565
+ const SWITCHABLE = ["storage"];
566
+ const EMPTY_S3 = Object.fromEntries(Object.keys(LOCAL_S3).map((key) => [key, key === "S3_CREATE_BUCKET" ? "0" : ""]));
567
+ /**
568
+ * Applies a new stack to a project's `.env`. Throws with a one-line reason
569
+ * for a change this version cannot make.
570
+ */
571
+ function applyStack(envText, env, from, to) {
572
+ let text = envText;
573
+ const changes = [];
574
+ const profiles = (env.COMPOSE_PROFILES ?? "").split(",").map((profile) => profile.trim()).filter(Boolean);
575
+ for (const option of STACK_OPTIONS) {
576
+ const key = option.key;
577
+ if (from[key] === to[key]) continue;
578
+ const value = option.values.find((candidate) => candidate.value === to[key]);
579
+ if (!value) throw new Error(`${JSON.stringify(to[key])} is not a ${option.key}.`);
580
+ 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.`);
581
+ if (key === "storage") {
582
+ if (profiles.includes("prod")) throw new Error("--storage only changes development; this .env runs the prod profile.");
583
+ const endpoint = env.S3_ENDPOINT_URL ?? "";
584
+ if (endpoint && !endpoint.includes("s3:8333")) throw new Error(`S3_ENDPOINT_URL points at ${endpoint}; change the S3_* values in .env yourself.`);
585
+ const next = profiles.filter((profile) => profile !== "s3");
586
+ if (to.storage === "s3") next.push("s3");
587
+ text = setEnvValues(text, {
588
+ ...to.storage === "s3" ? LOCAL_S3 : EMPTY_S3,
589
+ COMPOSE_PROFILES: next.join(",")
590
+ });
591
+ 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.");
592
+ }
593
+ }
594
+ return {
595
+ env: text,
596
+ changes
597
+ };
598
+ }
599
+ /** Sets the preset in the `django-stack` section of a project's `package.json` text. */
600
+ function setManifestPreset(manifestText, preset) {
601
+ const manifest = JSON.parse(manifestText);
602
+ manifest["django-stack"] = {
603
+ ...manifest["django-stack"] ?? {},
604
+ preset
605
+ };
606
+ return `${JSON.stringify(manifest, null, 2)}\n`;
607
+ }
608
+ // src/project.ts
609
+ /** The `.env` of `dstack run` on a development machine: the prod profile at https://site.localhost. */
610
+ const RUN_ENV = ".env.run";
611
+ const RUN_DOMAIN = "site.localhost";
612
+ /** The project directory: `start` or the nearest parent with `compose.yaml` and `.env`. */
613
+ function findProjectDirectory(start) {
614
+ let directory = resolve(start);
615
+ for (;;) {
616
+ if (existsSync(join(directory, "compose.yaml")) && existsSync(join(directory, ".env"))) return directory;
617
+ const parent = dirname(directory);
618
+ if (parent === directory) return void 0;
619
+ directory = parent;
620
+ }
621
+ }
622
+ function parseProfiles(value) {
623
+ return (value ?? "").split(",").map((profile) => profile.trim()).filter(Boolean);
624
+ }
625
+ function projectName(directory) {
626
+ try {
627
+ const match = readFileSync(join(directory, "compose.yaml"), "utf8").match(/^name:\s*["']?([\w.-]+)/m);
628
+ if (match?.[1]) return match[1];
629
+ } catch {}
630
+ return basename(directory).toLowerCase();
631
+ }
632
+ function fromEnv(directory, name, envFile, localRun) {
633
+ const env = parseEnv(readFileSync(join(directory, envFile), "utf8"));
634
+ const profiles = parseProfiles(env.COMPOSE_PROFILES);
635
+ const prod = profiles.includes("prod");
636
+ const suffix = prod ? "-prod" : "";
637
+ return {
638
+ directory,
639
+ name: localRun ? `${name}-run` : name,
640
+ env,
641
+ profiles,
642
+ prod,
643
+ localRun,
644
+ services: {
645
+ backend: `backend${suffix}`,
646
+ frontend: `frontend${suffix}`,
647
+ worker: `worker${suffix}`
648
+ },
649
+ composeArgs: localRun ? [
650
+ "--env-file",
651
+ envFile,
652
+ "--project-name",
653
+ `${name}-run`
654
+ ] : [],
655
+ composeEnv: localRun ? {
656
+ ENV_FILE: envFile,
657
+ COMPOSE_PROFILES: env.COMPOSE_PROFILES ?? ""
658
+ } : {}
659
+ };
660
+ }
661
+ /** Reads the project in or above `start`. Throws with a one-line reason when there is none. */
662
+ function readProject(start = ".") {
663
+ const directory = findProjectDirectory(start);
664
+ if (!directory) throw new Error(`No DjangoStack project in ${resolve(start)}: run this where compose.yaml and .env are.`);
665
+ return fromEnv(directory, projectName(directory), ".env", false);
666
+ }
667
+ /** The local production run of a development project, when `.env.run` exists. */
668
+ function readLocalRun(project) {
669
+ if (project.prod || project.localRun || !existsSync(join(project.directory, ".env.run"))) return void 0;
670
+ return fromEnv(project.directory, projectName(project.directory), RUN_ENV, true);
671
+ }
672
+ /** The `django-stack` section of the project's `package.json`. */
673
+ function projectManifest(project) {
674
+ try {
675
+ return JSON.parse(readFileSync(join(project.directory, "package.json"), "utf8"))["django-stack"] ?? {};
676
+ } catch {
677
+ return {};
678
+ }
679
+ }
680
+ /**
681
+ * The project's stack: from the preset `init` saved in `package.json`, or,
682
+ * for projects from before that, the defaults with the storage from `.env`.
683
+ */
684
+ function projectStack(project) {
685
+ const { preset } = projectManifest(project);
686
+ if (preset) try {
687
+ return decodePreset(preset);
688
+ } catch {}
689
+ return {
690
+ ...DEFAULT_STACK,
691
+ storage: project.profiles.includes("s3") ? "s3" : "local"
692
+ };
693
+ }
694
+ /** The URLs of a running project. */
695
+ function projectUrls(project) {
696
+ if (project.prod) {
697
+ const site = (project.env.SITE_URL ?? "").replace(/\/$/, "");
698
+ return [{
699
+ label: "Site",
700
+ url: `${site}/`
701
+ }, {
702
+ label: "Admin",
703
+ url: `${site}/admin/`
704
+ }];
705
+ }
706
+ const urls = [
707
+ {
708
+ label: "Site",
709
+ url: "http://localhost:3000"
710
+ },
711
+ {
712
+ label: "Admin",
713
+ url: "http://localhost:3000/admin/"
714
+ },
715
+ {
716
+ label: "Mail",
717
+ url: "http://localhost:8025"
718
+ }
719
+ ];
720
+ if (project.profiles.includes("s3")) urls.push({
721
+ label: "S3",
722
+ url: "http://localhost:8333"
723
+ });
724
+ return urls;
725
+ }
726
+ /** Runs a command with the terminal attached and returns its exit status. */
727
+ const runInherit = (cmd, args, options) => {
728
+ const result = spawnSync(cmd, args, {
729
+ stdio: "inherit",
730
+ ...options
731
+ });
732
+ if (result.error) return 127;
733
+ return result.status ?? 1;
734
+ };
735
+ /** The full `docker compose` argument list for a project. */
736
+ function composeCommand(project, args) {
737
+ return [
738
+ "compose",
739
+ ...project.composeArgs,
740
+ ...args
741
+ ];
742
+ }
743
+ function composeOptions(project) {
744
+ return {
745
+ cwd: project.directory,
746
+ env: {
747
+ ...process.env,
748
+ ...project.composeEnv
749
+ }
750
+ };
751
+ }
752
+ /** Runs `docker compose <args>` for the project with the terminal attached. */
753
+ function compose(project, args, run = runInherit) {
754
+ return run("docker", composeCommand(project, args), composeOptions(project));
755
+ }
756
+ /** Runs `docker compose <args>` and captures the output. */
757
+ function composeOutput(project, args) {
758
+ const result = spawnSync("docker", composeCommand(project, args), {
759
+ ...composeOptions(project),
760
+ encoding: "utf8",
761
+ stdio: [
762
+ "ignore",
763
+ "pipe",
764
+ "pipe"
765
+ ]
766
+ });
767
+ return {
768
+ status: result.status ?? 1,
769
+ output: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim()
770
+ };
771
+ }
772
+ /** The running services of the project. */
773
+ function runningServices(project) {
774
+ const result = spawnSync("docker", composeCommand(project, [
775
+ "ps",
776
+ "--services",
777
+ "--filter",
778
+ "status=running"
779
+ ]), {
780
+ ...composeOptions(project),
781
+ encoding: "utf8",
782
+ stdio: [
783
+ "ignore",
784
+ "pipe",
785
+ "ignore"
786
+ ]
787
+ });
788
+ return result.status === 0 ? result.stdout.split("\n").filter(Boolean) : [];
789
+ }
790
+ /**
791
+ * The project a command acts on: the local production run when it is the
792
+ * one running, otherwise the project `.env` describes.
793
+ */
794
+ function activeProject(project) {
795
+ const localRun = readLocalRun(project);
796
+ if (!localRun) return project;
797
+ if (runningServices(project).length) return project;
798
+ return runningServices(localRun).length ? localRun : project;
799
+ }
800
+ // src/commands/project.ts
801
+ function open(options) {
802
+ if (!has("docker", ["compose", "version"])) fail("Docker Compose v2 was not found. Install Docker: https://docs.docker.com/get-docker/");
803
+ try {
804
+ return readProject(options.dir ?? ".");
805
+ } catch (error) {
806
+ fail(error.message);
807
+ }
808
+ }
809
+ function urlsNote(project, title) {
810
+ const urls = projectUrls(project);
811
+ const width = Math.max(...urls.map((item) => item.label.length));
812
+ p.note(urls.map((item) => `${pc.bold(item.label.padEnd(width))} ${url(item.url)}`).join("\n"), title);
813
+ }
814
+ const tty = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
815
+ /** Exit with the status of a command, but treat Ctrl+C (130) as a normal stop. */
816
+ function exitWith(status) {
817
+ if (status !== 0 && status !== 130) process.exitCode = status;
818
+ }
819
+ /** Writes `.env.run` for the local production run of a development project, once. */
820
+ function ensureRunEnv(project) {
821
+ const path = join(project.directory, RUN_ENV);
822
+ if (!existsSync(path)) {
823
+ const result = productionEnv(readFileSync(join(project.directory, ".env"), "utf8"), {
824
+ domain: RUN_DOMAIN,
825
+ emailUrl: "console://"
826
+ });
827
+ writeFileSync(path, result.text, { mode: 384 });
828
+ p.log.success(`Wrote ${pc.bold(RUN_ENV)}: the prod profile at ${url(`https://${RUN_DOMAIN}`)}, with its own secrets and data.`);
829
+ p.log.message(`Admin user ${secret("admin")}, password ${secret(result.adminPassword)}`);
830
+ }
831
+ const run = readLocalRun(project);
832
+ if (!run) fail(`Could not read ${RUN_ENV}.`);
833
+ return run;
834
+ }
835
+ /** `dstack run`: the prod profile in the background, on a server or locally at https://site.localhost. */
836
+ async function run(options) {
837
+ const base = open(options);
838
+ intro("Run");
839
+ const project = base.prod ? base : ensureRunEnv(base);
840
+ const args = [
841
+ "up",
842
+ "--detach",
843
+ "--wait",
844
+ "--wait-timeout",
845
+ "300"
846
+ ];
847
+ if (options.build) args.push("--build");
848
+ if (compose(project, args) !== 0) fail(`The project did not start. See ${command("dstack logs")}.`);
849
+ urlsNote(project, "Running");
850
+ 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}.`));
851
+ p.outro(`Stop it with ${command("dstack stop")}, follow the logs with ${command("dstack logs")}`);
852
+ }
853
+ /** `dstack dev`: the dev profile in the foreground, with hot reload. */
854
+ async function dev(options) {
855
+ const project = open(options);
856
+ if (project.prod) fail(`This .env runs the prod profile. Use ${command("dstack run")}.`);
857
+ intro("Develop");
858
+ urlsNote(project, "Starting");
859
+ p.log.message(pc.dim("Changes in backend/ and frontend/ reload. Press Ctrl+C to stop."));
860
+ exitWith(compose(project, ["up", ...options.build ? ["--build"] : []]));
861
+ }
862
+ /** `dstack stop`: stop the containers of the project and of its local run; volumes and data stay. */
863
+ async function stop(options) {
864
+ const project = open(options);
865
+ for (const target of [project, readLocalRun(project)]) if (target && compose(target, ["down"]) !== 0) fail("The project did not stop.");
866
+ }
867
+ /** `dstack logs [service...]`. */
868
+ async function logs(services, options) {
869
+ const project = activeProject(open(options));
870
+ const args = ["logs", ...options.follow ? ["--follow"] : []];
871
+ if (options.tail) args.push("--tail", options.tail);
872
+ exitWith(compose(project, [...args, ...services]));
873
+ }
874
+ /** `exec` in a running service, otherwise a one-off container. */
875
+ function inService(project, service, args, interactive = tty()) {
876
+ const noTty = interactive ? [] : ["-T"];
877
+ if (runningServices(project).includes(service)) return [
878
+ "exec",
879
+ ...noTty,
880
+ service,
881
+ ...args
882
+ ];
883
+ return [
884
+ "run",
885
+ "--rm",
886
+ ...noTty,
887
+ service,
888
+ ...args
889
+ ];
890
+ }
891
+ /** `dstack manage <args...>`: Django's manage.py in the backend container. */
892
+ async function manage(args, options) {
893
+ const project = activeProject(open(options));
894
+ exitWith(compose(project, inService(project, project.services.backend, [
895
+ "python",
896
+ "manage.py",
897
+ ...args
898
+ ])));
899
+ }
900
+ /** `dstack shell`: Django's shell in the backend container. */
901
+ async function shell(options) {
902
+ return manage(["shell"], options);
903
+ }
904
+ /** The project that runs the backups: the prod profile, on a server or the local run. */
905
+ function backupProject(options) {
906
+ const project = activeProject(open(options));
907
+ if (!project.prod) fail(`Backups run in the prod profile: on the server, or locally after ${command("dstack run")}.`);
908
+ if (!runningServices(project).includes("backup")) fail(`The backup service is not running. Start the project with ${command("dstack run")}.`);
909
+ return project;
910
+ }
911
+ /** `dstack backup`: back up the database now, or list the backups. */
912
+ async function backup(options) {
913
+ exitWith(compose(backupProject(options), [
914
+ "exec",
915
+ "-T",
916
+ "backup",
917
+ ...[
918
+ "python",
919
+ "manage.py",
920
+ "backup_database",
921
+ ...options.list ? ["--list"] : []
922
+ ]
923
+ ]));
924
+ }
925
+ /** `dstack restore <backup>`: restore a backup over the database. */
926
+ async function restore(name, options) {
927
+ const project = backupProject(options);
928
+ const args = [
929
+ "python",
930
+ "manage.py",
931
+ "restore_database",
932
+ name,
933
+ ...options.yes ? ["--yes"] : []
934
+ ];
935
+ if (!options.yes && !tty()) fail("Pass --yes to restore without a terminal.");
936
+ exitWith(compose(project, [
937
+ "exec",
938
+ ...options.yes ? ["-T"] : [],
939
+ "backup",
940
+ ...args
941
+ ]));
942
+ }
943
+ /** `dstack types`: the OpenAPI schema from the backend, and the frontend's types from it. */
944
+ async function types(options) {
945
+ const project = open(options);
946
+ if (project.prod) fail("Generate the types in development, then commit them.");
947
+ intro("Types");
948
+ if (compose(project, inService(project, project.services.backend, [
949
+ "python",
950
+ "manage.py",
951
+ "generateschema",
952
+ "--generator_class",
953
+ "headless.rest.schema.SchemaGenerator",
954
+ "--format",
955
+ "openapi-json",
956
+ "--file",
957
+ "openapi.json"
958
+ ], false)) !== 0) fail("The backend could not write its schema.");
959
+ const generate = [
960
+ "npx",
961
+ "headless",
962
+ "generate",
963
+ "--file",
964
+ "/schema.json",
965
+ "--out",
966
+ "src/lib/headless.ts"
967
+ ];
968
+ if (compose(project, [
969
+ "run",
970
+ "--rm",
971
+ "--no-deps",
972
+ "-T",
973
+ ...["-v", "./backend/openapi.json:/schema.json:ro"],
974
+ project.services.frontend,
975
+ ...generate
976
+ ]) !== 0) fail("The frontend could not generate the types.");
977
+ p.outro(`Wrote ${pc.bold("frontend/src/lib/headless.ts")}`);
978
+ }
979
+ /** The code checks of `dstack check`. */
980
+ function checkSteps(project, tests) {
981
+ const steps = [
982
+ {
983
+ name: project.prod ? "Django deploy checks" : "Django system checks",
984
+ service: "backend",
985
+ args: project.prod ? [
986
+ "python",
987
+ "manage.py",
988
+ "check",
989
+ "--deploy",
990
+ "--fail-level",
991
+ "WARNING"
992
+ ] : [
993
+ "python",
994
+ "manage.py",
995
+ "check"
996
+ ]
997
+ },
998
+ {
999
+ name: "Migrations are up to date",
1000
+ service: "backend",
1001
+ args: [
1002
+ "python",
1003
+ "manage.py",
1004
+ "makemigrations",
1005
+ "--check",
1006
+ "--dry-run"
1007
+ ]
1008
+ },
1009
+ {
1010
+ name: "Ruff",
1011
+ service: "backend",
1012
+ args: [
1013
+ "ruff",
1014
+ "check",
1015
+ "."
1016
+ ],
1017
+ devOnly: true
1018
+ },
1019
+ {
1020
+ name: "Astro check",
1021
+ service: "frontend",
1022
+ args: [
1023
+ "npm",
1024
+ "run",
1025
+ "--silent",
1026
+ "check"
1027
+ ],
1028
+ devOnly: true
1029
+ }
1030
+ ];
1031
+ if (tests) steps.push({
1032
+ name: "Backend tests",
1033
+ service: "backend",
1034
+ args: ["pytest", "-q"],
1035
+ devOnly: true
1036
+ }, {
1037
+ name: "Frontend tests",
1038
+ service: "frontend",
1039
+ args: [
1040
+ "npm",
1041
+ "test",
1042
+ "--silent"
1043
+ ],
1044
+ devOnly: true
1045
+ });
1046
+ return steps.filter((step) => !(step.devOnly && project.prod));
1047
+ }
1048
+ /** `dstack check`: the doctor's checks, Django's checks, the linters and, with --tests, the tests. */
1049
+ async function check(options) {
1050
+ const project = activeProject(open(options));
1051
+ intro("Check");
1052
+ let failed = 0;
1053
+ const spinner = p.spinner();
1054
+ if (!project.localRun) {
1055
+ spinner.start("Checking the setup");
1056
+ const doctorChecks = await runDoctor(project.directory);
1057
+ spinner.clear();
1058
+ for (const item of doctorChecks) {
1059
+ const line = item.detail ? `${item.name} ${pc.dim(`- ${item.detail}`)}` : item.name;
1060
+ if (item.ok) p.log.success(line);
1061
+ else if (item.warning) p.log.warn(pc.yellow(line));
1062
+ else {
1063
+ p.log.error(pc.red(line));
1064
+ failed++;
1065
+ }
1066
+ }
1067
+ }
1068
+ const running = runningServices(project);
1069
+ for (const step of checkSteps(project, Boolean(options.tests))) {
1070
+ const service = project.services[step.service];
1071
+ const args = running.includes(service) ? [
1072
+ "exec",
1073
+ "-T",
1074
+ service,
1075
+ ...step.args
1076
+ ] : [
1077
+ "run",
1078
+ "--rm",
1079
+ "-T",
1080
+ ...step.service === "frontend" ? ["--no-deps"] : [],
1081
+ service,
1082
+ ...step.args
1083
+ ];
1084
+ spinner.start(step.name);
1085
+ const result = composeOutput(project, args);
1086
+ if (result.status === 0) {
1087
+ spinner.stop(step.name);
1088
+ continue;
1089
+ }
1090
+ failed++;
1091
+ spinner.error(pc.red(step.name));
1092
+ if (result.output) p.log.message(pc.dim(result.output.split("\n").slice(-40).join("\n")));
1093
+ }
1094
+ if (failed) {
1095
+ p.outro(pc.red(`${failed} ${failed === 1 ? "check" : "checks"} failed`));
1096
+ process.exitCode = 1;
1097
+ return;
1098
+ }
1099
+ p.outro(pc.green("All checks passed"));
1100
+ }
1101
+ /** The name of the frontend's node_modules volume, which hides new packages after a rebuild. */
1102
+ function nodeModulesVolume(project) {
1103
+ const result = composeOutput(project, [
1104
+ "config",
1105
+ "--format",
1106
+ "json"
1107
+ ]);
1108
+ if (result.status !== 0) return void 0;
1109
+ try {
1110
+ return JSON.parse(result.output).volumes?.frontend_node_modules?.name;
1111
+ } catch {
1112
+ return;
1113
+ }
1114
+ }
1115
+ /** `dstack update`: newer images, newer DjangoStack packages, and a restart of what was running. */
1116
+ async function update(options) {
1117
+ const project = open(options);
1118
+ intro("Update");
1119
+ const targets = [project, readLocalRun(project)].filter((target) => Boolean(target));
1120
+ const running = targets.filter((target) => runningServices(target).length > 0);
1121
+ const lock = join(project.directory, "backend", "uv.lock");
1122
+ if (existsSync(lock) && has("uv")) {
1123
+ const args = options.all ? ["lock", "--upgrade"] : [
1124
+ "lock",
1125
+ "--upgrade-package",
1126
+ "django-stack"
1127
+ ];
1128
+ if (spawnSync("uv", args, {
1129
+ cwd: join(project.directory, "backend"),
1130
+ stdio: "inherit"
1131
+ }).status !== 0) fail("uv could not update backend/uv.lock.");
1132
+ p.log.success(options.all ? "Updated backend/uv.lock" : "Updated django-stack in backend/uv.lock");
1133
+ } else if (existsSync(lock)) p.log.warn("uv is not installed, so backend/uv.lock stays as it is: https://docs.astral.sh/uv/");
1134
+ for (const target of targets) {
1135
+ if (compose(target, ["pull", "--ignore-buildable"]) !== 0) fail("Pulling the images failed.");
1136
+ if (compose(target, [
1137
+ "build",
1138
+ "--pull",
1139
+ ...options.cache ? [] : ["--no-cache"]
1140
+ ]) !== 0) fail("Building the images failed.");
1141
+ }
1142
+ const volume = project.prod ? void 0 : nodeModulesVolume(project);
1143
+ if (volume) {
1144
+ compose(project, [
1145
+ "rm",
1146
+ "--stop",
1147
+ "--force",
1148
+ project.services.frontend
1149
+ ]);
1150
+ spawnSync("docker", [
1151
+ "volume",
1152
+ "rm",
1153
+ "--force",
1154
+ volume
1155
+ ], { stdio: "ignore" });
1156
+ }
1157
+ if (options.restart && running.length) {
1158
+ for (const target of running) if (compose(target, [
1159
+ "up",
1160
+ "--detach",
1161
+ "--wait",
1162
+ "--wait-timeout",
1163
+ "300"
1164
+ ]) !== 0) fail(`The project did not start again. See ${command("dstack logs")}.`);
1165
+ p.outro(pc.green("Updated and restarted"));
1166
+ return;
1167
+ }
1168
+ p.outro(pc.green(`Updated. Start it with ${command(project.prod ? "dstack run" : "dstack dev")}`));
1169
+ }
1170
+ /** `dstack config`: change the stack options of an existing project. */
1171
+ async function config(options) {
1172
+ let project;
1173
+ try {
1174
+ project = readProject(options.dir ?? ".");
1175
+ } catch (error) {
1176
+ fail(error.message);
1177
+ }
1178
+ const interactive = isInteractive(options.yes);
1179
+ intro("Configure");
1180
+ const current = projectStack(project);
1181
+ const next = { ...current };
1182
+ for (const option of STACK_OPTIONS) {
1183
+ const key = option.key;
1184
+ if (options.fromCli(key)) next[key] = options[key];
1185
+ else if (interactive && SWITCHABLE.includes(key)) next[key] = unwrap(await p.select({
1186
+ message: option.message,
1187
+ initialValue: current[key],
1188
+ options: option.values.map((value) => ({
1189
+ value: value.value,
1190
+ label: value.label,
1191
+ hint: value.generated ? value.hint : "coming soon",
1192
+ disabled: !value.generated && value.value !== current[key]
1193
+ }))
1194
+ }));
1195
+ }
1196
+ const envPath = join(project.directory, ".env");
1197
+ let result;
1198
+ try {
1199
+ result = applyStack(readFileSync(envPath, "utf8"), project.env, current, next);
1200
+ } catch (error) {
1201
+ fail(error.message);
1202
+ }
1203
+ if (!result.changes.length) {
1204
+ p.outro("Nothing changed");
1205
+ return;
1206
+ }
1207
+ writeFileSync(envPath, result.env, { mode: 384 });
1208
+ const manifestPath = join(project.directory, "package.json");
1209
+ if (existsSync(manifestPath)) writeFileSync(manifestPath, setManifestPreset(readFileSync(manifestPath, "utf8"), encodePreset(next)));
1210
+ for (const change of result.changes) p.log.success(change);
1211
+ p.outro(`Restart to apply it: ${command("dstack stop")}, then ${command("dstack dev")}`);
1212
+ }
1213
+ // src/program.ts
1214
+ /** Commands that print machine-readable output, so no update notice. */
1215
+ const QUIET_COMMANDS = /* @__PURE__ */ new Set([
1216
+ "secret",
1217
+ "complete",
1218
+ "completion"
1219
+ ]);
1220
+ /** A flag for each stack option, such as `--cache <cache>`. */
1221
+ function stackFlags() {
1222
+ return STACK_OPTIONS.map((option) => {
1223
+ const name = option.flag.slice(2);
1224
+ return new Option(`${option.flag} <${name}>`, `${option.description} (default: ${option.values[0].value})`).choices(option.values.map((value) => value.value));
1225
+ });
1226
+ }
1227
+ function positiveInteger(value) {
1228
+ const number = Number(value);
1229
+ if (!Number.isInteger(number) || number < 1) throw new InvalidArgumentError("Pass a whole number above 0.");
1230
+ return number;
1231
+ }
1232
+ function createProgram() {
1233
+ 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({
1234
+ sortSubcommands: false,
1235
+ styleTitle: (text) => pc.bold(text),
1236
+ styleCommandText: (text) => pc.green(text),
1237
+ styleSubcommandText: (text) => pc.green(text),
1238
+ styleOptionText: (text) => pc.cyan(text),
1239
+ styleArgumentText: (text) => pc.yellow(text),
1240
+ styleDescriptionText: (text) => text
1241
+ }).configureOutput({ outputError: (text, write) => write(pc.red(text)) });
1242
+ 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, {
1243
+ ...options,
1244
+ installPrompt: program.opts().installPrompt,
1245
+ fromCli: (option) => cmd.getOptionValueSource(option) === "cli"
1246
+ }));
1247
+ for (const flag of stackFlags()) initCommand.addOption(flag);
1248
+ const dirOption = () => new Option("-C, --dir <directory>", "The project directory (default: this one or a parent)");
1249
+ 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);
1250
+ program.command("dev").description("Run the development servers with hot reload").addOption(dirOption()).option("--no-build", "Don't build the images first").action(dev);
1251
+ program.command("stop").description("Stop the project; the data stays").addOption(dirOption()).action(stop);
1252
+ 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);
1253
+ 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);
1254
+ 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);
1255
+ 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({
1256
+ ...options,
1257
+ fromCli: (option) => cmd.getOptionValueSource(option) === "cli"
1258
+ }));
1259
+ for (const flag of stackFlags()) configCommand.addOption(flag);
1260
+ program.command("manage").description("Run a Django management command in the backend").argument("<args...>", "The command and its options, such as createsuperuser").addOption(dirOption()).allowUnknownOption().helpOption(false).action((args, options) => manage(args, options));
1261
+ program.command("shell").description("Open Django's shell in the backend").addOption(dirOption()).action(shell);
1262
+ program.command("backup").description("Back up the database now (prod profile)").addOption(dirOption()).option("--list", "List the backups instead").action(backup);
1263
+ program.command("restore").description("Restore a backup over the database (prod profile)").argument("<backup>", "A file name from `dstack backup --list`").addOption(dirOption()).option("-y, --yes", "Don't ask for confirmation").action(restore);
1264
+ program.command("types").description("Generate the frontend's API types from the backend's schema").addOption(dirOption()).action(types);
1265
+ const presetCommand = program.command("preset").description("Print the preset code of a stack, or the stack of a code").argument("[preset]", "A preset code or name to show").option("-y, --yes", "Don't ask; use the flags and the defaults").action((code, options, cmd) => preset(code, {
1266
+ ...options,
1267
+ fromCli: (option) => cmd.getOptionValueSource(option) === "cli"
1268
+ }));
1269
+ for (const flag of stackFlags()) presetCommand.addOption(flag);
1270
+ 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);
1271
+ program.command("doctor").description("Check a project and its services").argument("[directory]", "The project directory", ".").action(doctor);
1272
+ program.command("secret").description("Print a random secret").argument("[bytes]", "The number of random bytes", positiveInteger, 32).action((bytes) => console.log(generateSecret(bytes)));
1273
+ tab(program, { completionCommandName: "completion" });
1274
+ 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)")}`);
1275
+ let update$1;
1276
+ let offerInstall = false;
1277
+ program.hook("preAction", (_root, action) => {
1278
+ if (QUIET_COMMANDS.has(action.name())) return;
1279
+ offerInstall = program.opts().installPrompt !== false && !action.opts().yes;
1280
+ if (program.opts().updateCheck === false || !updateCheckEnabled()) return;
1281
+ update$1 = checkForUpdate({ current: CLI_VERSION });
1282
+ });
1283
+ program.hook("postAction", async () => {
1284
+ if (offerInstall && await offerGlobalInstall()) return;
1285
+ const latest = await update$1;
1286
+ if (!latest) return;
1287
+ p.box([`${pc.dim(CLI_VERSION)} ${pc.dim("→")} ${pc.green(pc.bold(latest))}`, `Run ${pc.cyan(updateCommand())}`].join("\n"), pc.yellow("Update available"), {
1288
+ formatBorder: (text) => pc.yellow(text),
1289
+ contentAlign: "center",
1290
+ titleAlign: "center",
1291
+ width: "auto",
1292
+ rounded: true,
1293
+ withGuide: false
1294
+ });
1295
+ });
1296
+ return program;
1297
+ }
3
1298
  // src/bin.ts
4
- console.log(`django-stack ${VERSION}\n\nThe init command arrives in the next release.`);
1299
+ const program = createProgram();
1300
+ if (process.argv.length <= 2) program.outputHelp();
1301
+ else program.parseAsync().catch((error) => {
1302
+ console.error(pc.red(error instanceof Error ? error.message : String(error)));
1303
+ process.exit(1);
1304
+ });
5
1305
  export {};