@django-stack/cli 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -4,6 +4,8 @@ interface Check {
4
4
  /** Not a failure: the check could not run or only warns. */
5
5
  warning?: boolean;
6
6
  detail?: string;
7
+ /** How to fix a failed check, one line each. */
8
+ help?: string[];
7
9
  }
8
10
  /** Checks a project directory: tools, .env, and the running services. */
9
11
  export declare function runDoctor(directory: string): Promise<Check[]>;
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export { t as DEFAULT_STACK, u as FRONTENDS, n as NAMED_PRESETS, r as STACK_OPTIONS, b as VERSION, a as decodePreset, o as encodePreset, C as generateProjectSecrets, w as generateSecret, p as normalizeDatabaseUrl, E as parseEnv, s as pendingChoices, x as productionEnv, c as resolvePreset, T as runDoctor, h as scaffold, D as setEnvValues, _ as toProjectName, S as validateDomain, v as validateProjectName } from "./stack-OTrWHXrO.js";
1
+ export { t as DEFAULT_STACK, u as FRONTENDS, n as NAMED_PRESETS, r as STACK_OPTIONS, b as VERSION, a as decodePreset, o as encodePreset, C as generateProjectSecrets, w as generateSecret, p as normalizeDatabaseUrl, D as parseEnv, s as pendingChoices, x as productionEnv, c as resolvePreset, T as runDoctor, h as scaffold, O as setEnvValues, _ as toProjectName, S as validateDomain, v as validateProjectName } from "./stack-CTyqYWCV.js";
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
- import { execFileSync } from "node:child_process";
3
2
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
4
3
  import { join, relative } from "node:path";
4
+ import { execFileSync } from "node:child_process";
5
5
  import { randomBytes } from "node:crypto";
6
6
  import { fileURLToPath } from "node:url";
7
7
  // src/env.ts
@@ -28,17 +28,10 @@ function setEnvValues(text, values) {
28
28
  }
29
29
  return result;
30
30
  }
31
- // src/doctor.ts
32
- const REQUIRED_KEYS = [
33
- "DJANGO_SECRET_KEY",
34
- "DATABASE_URL",
35
- "API_SECRET_KEY",
36
- "API_READ_KEY",
37
- "PREVIEW_SECRET",
38
- "REVALIDATE_SECRET",
39
- "SITE_URL"
40
- ];
41
- function command(cmd, args, cwd) {
31
+ /** The oldest Python the backend runs on. */
32
+ const MIN_PYTHON = "3.12";
33
+ /** Runs a command and returns its trimmed output, or undefined when it fails. */
34
+ const run = (cmd, args, cwd) => {
42
35
  try {
43
36
  return execFileSync(cmd, args, {
44
37
  cwd,
@@ -52,32 +45,69 @@ function command(cmd, args, cwd) {
52
45
  } catch {
53
46
  return;
54
47
  }
48
+ };
49
+ /** The first `x.y.z` version in a text, such as `Python 3.13.1` or `v22.11.0`. */
50
+ function parseVersion(text) {
51
+ return text?.match(/(\d+)\.(\d+)(?:\.(\d+))?/)?.[0];
55
52
  }
56
- async function fetchStatus(url, headers = {}) {
57
- try {
58
- return (await fetch(url, {
59
- headers,
60
- redirect: "manual",
61
- signal: AbortSignal.timeout(5e3)
62
- })).status;
63
- } catch {
64
- return;
53
+ /** Whether version `a` is at least `b`, compared by number per part. */
54
+ function atLeast(a, b) {
55
+ const left = a.split(".").map(Number);
56
+ const right = b.split(".").map(Number);
57
+ for (let i = 0; i < Math.max(left.length, right.length); i++) {
58
+ const x = left[i] ?? 0;
59
+ const y = right[i] ?? 0;
60
+ if (x !== y) return x > y;
65
61
  }
62
+ return true;
66
63
  }
67
- /** Checks a project directory: tools, .env, and the running services. */
68
- async function runDoctor(directory) {
69
- const checks = [];
70
- const docker = command("docker", [
64
+ function dockerHelp(platform) {
65
+ if (platform === "darwin") return ["Install Docker Desktop: brew install --cask docker", "or https://docs.docker.com/desktop/setup/install/mac-install/"];
66
+ if (platform === "win32") return ["Install Docker Desktop: winget install Docker.DockerDesktop", "or https://docs.docker.com/desktop/setup/install/windows-install/"];
67
+ return ["Install Docker Engine and the Compose plugin: https://docs.docker.com/engine/install/"];
68
+ }
69
+ function dockerStartHelp(platform) {
70
+ if (platform === "linux") return ["Start it: sudo systemctl start docker"];
71
+ return ["Open Docker Desktop and wait until it says it is running."];
72
+ }
73
+ function nodeHelp(platform) {
74
+ return [`Install it with fnm: ${platform === "darwin" ? "brew install fnm" : platform === "win32" ? "winget install Schniz.fnm" : "curl -fsSL https://fnm.vercel.app/install | bash"}, then fnm install 22`, "or https://nodejs.org/en/download"];
75
+ }
76
+ function pythonHelp(platform, hasUv) {
77
+ const python = "uv python install 3.13";
78
+ if (hasUv) return [`Install it with uv: ${python}`];
79
+ return [`Install uv: ${platform === "darwin" ? "brew install uv" : platform === "win32" ? "winget install astral-sh.uv" : "curl -LsSf https://astral.sh/uv/install.sh | sh"}, then ${python}`, "or https://docs.astral.sh/uv/getting-started/installation/"];
80
+ }
81
+ function pythonVersion(runCommand) {
82
+ for (const [cmd, args] of [
83
+ ["python3", ["--version"]],
84
+ ["python", ["--version"]],
85
+ ["py", ["-3", "--version"]]
86
+ ]) {
87
+ const version = parseVersion(runCommand(cmd, [...args]));
88
+ if (version) return version;
89
+ }
90
+ }
91
+ /** Docker and Docker Compose, which run the project. */
92
+ function checkDocker({ run: runCommand = run, platform = process.platform } = {}) {
93
+ const client = parseVersion(runCommand("docker", ["--version"]));
94
+ if (!client) return [{
95
+ name: "Docker is installed",
96
+ ok: false,
97
+ help: dockerHelp(platform)
98
+ }];
99
+ const server = runCommand("docker", [
71
100
  "version",
72
101
  "--format",
73
102
  "{{.Server.Version}}"
74
103
  ]);
75
- checks.push({
104
+ const checks = [{
76
105
  name: "Docker is running",
77
- ok: Boolean(docker),
78
- detail: docker ? `Docker ${docker}` : "Install Docker and start it: https://docs.docker.com/get-docker/"
79
- });
80
- const compose = command("docker", [
106
+ ok: Boolean(server),
107
+ detail: server ? `Docker ${server}` : `Docker ${client} is installed but not running.`,
108
+ help: server ? void 0 : dockerStartHelp(platform)
109
+ }];
110
+ const compose = runCommand("docker", [
81
111
  "compose",
82
112
  "version",
83
113
  "--short"
@@ -85,8 +115,66 @@ async function runDoctor(directory) {
85
115
  checks.push({
86
116
  name: "Docker Compose v2",
87
117
  ok: Boolean(compose),
88
- detail: compose ? `Compose ${compose}` : "Docker Compose v2 is part of Docker Desktop and the docker-compose-plugin package."
118
+ detail: compose ? `Compose ${compose}` : void 0,
119
+ help: compose ? void 0 : ["Docker Compose v2 is part of Docker Desktop and the docker-compose-plugin package."]
89
120
  });
121
+ return checks;
122
+ }
123
+ /**
124
+ * Node.js and Python, which you need to work on the frontend and the backend
125
+ * outside the containers. They only warn: the project runs in Docker without them.
126
+ */
127
+ function checkDevTools({ run: runCommand = run, platform = process.platform } = {}) {
128
+ const checks = [];
129
+ const node = parseVersion(runCommand("node", ["--version"]));
130
+ const nodeOk = Boolean(node && atLeast(node, "22"));
131
+ checks.push({
132
+ name: `Node.js 22 or later`,
133
+ ok: nodeOk,
134
+ warning: !nodeOk,
135
+ detail: node ? `Node.js ${node}` : "Node.js was not found.",
136
+ help: nodeOk ? void 0 : nodeHelp(platform)
137
+ });
138
+ const python = pythonVersion(runCommand);
139
+ const pythonOk = Boolean(python && atLeast(python, "3.12"));
140
+ checks.push({
141
+ name: `Python ${MIN_PYTHON} or later`,
142
+ ok: pythonOk,
143
+ warning: !pythonOk,
144
+ detail: python ? `Python ${python}` : "Python was not found.",
145
+ help: pythonOk ? void 0 : pythonHelp(platform, Boolean(runCommand("uv", ["--version"])))
146
+ });
147
+ return checks;
148
+ }
149
+ /** Every tool a project needs on this computer. */
150
+ function checkTools(options = {}) {
151
+ return [...checkDocker(options), ...checkDevTools(options)];
152
+ }
153
+ // src/doctor.ts
154
+ const REQUIRED_KEYS = [
155
+ "DJANGO_SECRET_KEY",
156
+ "DATABASE_URL",
157
+ "API_SECRET_KEY",
158
+ "API_READ_KEY",
159
+ "PREVIEW_SECRET",
160
+ "REVALIDATE_SECRET",
161
+ "SITE_URL"
162
+ ];
163
+ async function fetchStatus(url, headers = {}) {
164
+ try {
165
+ return (await fetch(url, {
166
+ headers,
167
+ redirect: "manual",
168
+ signal: AbortSignal.timeout(5e3)
169
+ })).status;
170
+ } catch {
171
+ return;
172
+ }
173
+ }
174
+ /** Checks a project directory: tools, .env, and the running services. */
175
+ async function runDoctor(directory) {
176
+ const checks = [];
177
+ checks.push(...checkTools());
90
178
  const envPath = join(directory, ".env");
91
179
  if (!existsSync(envPath)) {
92
180
  checks.push({
@@ -131,7 +219,7 @@ async function runDoctor(directory) {
131
219
  detail: "Set EMAIL_URL to your SMTP server; Mailpit only runs in development."
132
220
  });
133
221
  }
134
- const running = command("docker", [
222
+ const running = run("docker", [
135
223
  "compose",
136
224
  "ps",
137
225
  "--services",
@@ -148,7 +236,7 @@ async function runDoctor(directory) {
148
236
  detail: services.length ? services.join(", ") : `Nothing is running. Start the project with \`docker compose up${prod ? " -d" : ""}\`.`
149
237
  });
150
238
  if (services.length === 0) return checks;
151
- const health = command("docker", [
239
+ const health = run("docker", [
152
240
  "compose",
153
241
  "ps",
154
242
  backendService,
@@ -709,4 +797,4 @@ function pendingChoices(stack) {
709
797
  }];
710
798
  });
711
799
  }
712
- export { generateProjectSecrets as C, setEnvValues as D, parseEnv as E, validateDomain as S, runDoctor as T, toProjectName as _, decodePreset as a, CLI_VERSION as b, resolvePreset as c, LOCAL_S3 as d, lockBackend as f, timeZones as g, scaffold as h, applies as i, stackOption as l, relativeDirectory as m, NAMED_PRESETS as n, encodePreset as o, normalizeDatabaseUrl as p, STACK_OPTIONS as r, pendingChoices as s, DEFAULT_STACK as t, FRONTENDS as u, validateProjectName as v, generateSecret as w, productionEnv as x, validateTimeZone as y };
800
+ export { generateProjectSecrets as C, parseEnv as D, checkTools as E, setEnvValues as O, validateDomain as S, runDoctor as T, toProjectName as _, decodePreset as a, CLI_VERSION as b, resolvePreset as c, LOCAL_S3 as d, lockBackend as f, timeZones as g, scaffold as h, applies as i, stackOption as l, relativeDirectory as m, NAMED_PRESETS as n, encodePreset as o, normalizeDatabaseUrl as p, STACK_OPTIONS as r, pendingChoices as s, DEFAULT_STACK as t, FRONTENDS as u, validateProjectName as v, generateSecret as w, productionEnv as x, validateTimeZone as y };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@django-stack/cli",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "The production-ready Django, batteries included stack. Create and manage DjangoStack projects: a Django backend, a frontend, Postgres and Docker Compose in one command.",
5
5
  "license": "MIT",
6
6
  "author": "Leon van der Grient",
@@ -44,6 +44,7 @@
44
44
  "dependencies": {
45
45
  "@bomb.sh/tab": "^0.0.22",
46
46
  "@clack/prompts": "^1.8.1",
47
+ "@napi-rs/keyring": "^2.1.2",
47
48
  "commander": "^15.0.0",
48
49
  "picocolors": "^1.1.1"
49
50
  },
@@ -5,7 +5,7 @@ description = "The Django backend of {{project_name}}."
5
5
  requires-python = ">=3.12"
6
6
  dependencies = [
7
7
  "Django>=6.1,<6.2",
8
- "django-stack[server,s3]>=0.1.0",
8
+ "django-stack[server,s3]>=0.3.0",
9
9
  "django-headless[schema]>=1.1.1",
10
10
  "django-content-studio[images,huey]>=1.8.0",
11
11
  ]
@@ -43,7 +43,8 @@ Run them at the project root. They need Docker; Node is only needed for the `npm
43
43
  | Production settings for a server | `npx @django-stack/cli production --domain example.com` |
44
44
 
45
45
  In production (`COMPOSE_PROFILES=prod,db`) the services are `backend-prod`,
46
- `worker-prod`, `frontend-prod`, `proxy` and `backup`; their containers are
46
+ `worker-prod` (which also runs the daily backup), `frontend-prod` and
47
+ `proxy`; their containers are
47
48
  read-only, so a change goes through the code and a new image, never an edit
48
49
  in a running container.
49
50
 
@@ -37,7 +37,7 @@ the frontend Astro's, with the source mounted from `backend/` and
37
37
  | Run the tests | `npm test` |
38
38
  | Check the setup | `npm run doctor` |
39
39
 
40
- With the `dstack` command installed (`npm install --global @django-stack/cli`),
40
+ With the `dstack` command installed (`npm i -g @django-stack/cli`),
41
41
  `dstack dev`, `dstack check`, `dstack manage <command>` and `dstack types` do
42
42
  the same from any directory in the project. See the
43
43
  [CLI reference](https://dwarsbit.github.io/django-stack/docs/cli).
@@ -70,9 +70,9 @@ has every step.
70
70
  | Task | Command (on the server) |
71
71
  |------|---------|
72
72
  | Update | `git pull && docker compose up -d --build` |
73
- | Back up now | `docker compose exec backup python manage.py backup_database` |
74
- | List backups | `docker compose exec backup python manage.py backup_database --list` |
75
- | Restore | `docker compose exec backup python manage.py restore_database <name>` |
73
+ | Back up the database and media now | `docker compose exec worker-prod python manage.py backup_database` |
74
+ | List backups | `docker compose exec worker-prod python manage.py backup_database --list` |
75
+ | Restore | `docker compose exec worker-prod python manage.py restore_database <name> --media` |
76
76
  | Logs | `docker compose logs --follow backend-prod` |
77
77
 
78
78
  ## Security
@@ -4,7 +4,7 @@
4
4
  # dev,db `docker compose up` on a laptop: dev servers with hot reload,
5
5
  # http://localhost:3000, Mailpit at http://localhost:8025.
6
6
  # prod,db `docker compose up -d` on a server: production images behind
7
- # Caddy on ports 80 and 443, with automatic HTTPS and backups.
7
+ # Caddy on ports 80 and 443, with automatic HTTPS and daily backups.
8
8
  # Leave out `db` to use an external Postgres through DATABASE_URL, and add
9
9
  # `s3` in development for a local S3 service (see .env).
10
10
  #
@@ -156,11 +156,17 @@ services:
156
156
  <<: *hardened
157
157
  env_file: ${ENV_FILE:-.env}
158
158
  command: ["python", "manage.py", "run_huey"]
159
+ # Also runs the daily backup of the database, and of the media volume
160
+ # when uploads are not in S3, at BACKUP_TIME (UTC) into the backups
161
+ # volume and BACKUP_S3_BUCKET. See README.md.
159
162
  environment:
160
163
  DJANGO_DEBUG: "0"
161
164
  NUM_PROXIES: ${NUM_PROXIES:-1}
165
+ BACKUP_DAILY: "1"
166
+ BACKUP_DIR: /app/backups
162
167
  volumes:
163
168
  - media:/app/media
169
+ - backups:/app/backups
164
170
  depends_on:
165
171
  backend-prod:
166
172
  condition: service_healthy
@@ -224,33 +230,6 @@ services:
224
230
  memory: 256M
225
231
  restart: unless-stopped
226
232
 
227
- # Backs up the database every day at BACKUP_TIME (UTC) into the backups
228
- # volume, and into BACKUP_S3_BUCKET when it is set. See README.md.
229
- backup:
230
- profiles: [prod]
231
- image: ${BACKEND_IMAGE:-{{project_name}}-backend}
232
- build:
233
- context: ./backend
234
- target: prod
235
- <<: *hardened
236
- env_file: ${ENV_FILE:-.env}
237
- command: ["python", "manage.py", "run_backups"]
238
- environment:
239
- DJANGO_DEBUG: "0"
240
- BACKUP_DIR: /app/backups
241
- volumes:
242
- - backups:/app/backups
243
- depends_on:
244
- backend-prod:
245
- condition: service_healthy
246
- healthcheck:
247
- disable: true
248
- deploy:
249
- resources:
250
- limits:
251
- memory: 256M
252
- restart: unless-stopped
253
-
254
233
  # Shared -----------------------------------------------------------------
255
234
 
256
235
  db:
@@ -71,10 +71,13 @@ S3_ACCESS_KEY_ID=
71
71
  S3_SECRET_ACCESS_KEY=
72
72
  S3_PUBLIC_URL=
73
73
 
74
- # Backups (prod profile): a daily pg_dump at BACKUP_TIME (UTC), the newest
75
- # BACKUP_KEEP kept in the backups volume. Set BACKUP_S3_BUCKET to a private
76
- # bucket (never the public media bucket) to keep a copy off the server; it
77
- # uses the S3_* credentials unless BACKUP_S3_* ones are set.
74
+ # Backups (prod profile): a daily pg_dump at BACKUP_TIME (UTC) and, when
75
+ # uploads are on disk (no S3_BUCKET) and BACKUP_MEDIA=1, an archive of the
76
+ # media volume; the newest BACKUP_KEEP of each kept in the backups volume.
77
+ # Set BACKUP_S3_BUCKET to a private bucket (never the public media bucket)
78
+ # to keep a copy off the server; it uses the S3_* credentials unless
79
+ # BACKUP_S3_* ones are set.
78
80
  BACKUP_TIME=03:00
79
81
  BACKUP_KEEP=14
82
+ BACKUP_MEDIA=1
80
83
  BACKUP_S3_BUCKET=
@@ -1,4 +1,5 @@
1
1
  .env
2
+ .env.production
2
3
  .env.run
3
4
  node_modules/
4
5
  backend/.venv/
@@ -16,7 +16,7 @@
16
16
  "@astrojs/node": "^11.1.9",
17
17
  "@astrojs/rss": "^4.0.19",
18
18
  "@django-headless/client": "^1.1.1",
19
- "@django-stack/utils": "^0.1.0",
19
+ "@django-stack/utils": "^0.3.0",
20
20
  "astro": "^7.4.5"
21
21
  },
22
22
  "devDependencies": {