@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.
Files changed (79) hide show
  1. package/README.md +27 -3
  2. package/dist/bin.js +2282 -2
  3. package/dist/index.d.ts +252 -1
  4. package/dist/index.js +1 -4
  5. package/dist/stack-CTyqYWCV.js +800 -0
  6. package/package.json +15 -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 +79 -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 +282 -0
  37. package/templates/base/env.example +83 -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
@@ -0,0 +1,800 @@
1
+ import { createRequire } from "node:module";
2
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { join, relative } from "node:path";
4
+ import { execFileSync } from "node:child_process";
5
+ import { randomBytes } from "node:crypto";
6
+ import { fileURLToPath } from "node:url";
7
+ // src/env.ts
8
+ /** Reads a .env file: KEY=value lines, comments and blank lines ignored. */
9
+ function parseEnv(text) {
10
+ const values = {};
11
+ for (const line of text.split(/\r?\n/)) {
12
+ const trimmed = line.trim();
13
+ if (!trimmed || trimmed.startsWith("#")) continue;
14
+ const index = trimmed.indexOf("=");
15
+ if (index <= 0) continue;
16
+ let value = trimmed.slice(index + 1).trim();
17
+ if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
18
+ values[trimmed.slice(0, index).trim()] = value;
19
+ }
20
+ return values;
21
+ }
22
+ /** Sets KEY=value lines in .env text, replacing existing ones and appending new ones. */
23
+ function setEnvValues(text, values) {
24
+ let result = text;
25
+ for (const [key, value] of Object.entries(values)) {
26
+ const pattern = new RegExp(`^${key}=.*$`, "m");
27
+ result = pattern.test(result) ? result.replace(pattern, () => `${key}=${value}`) : `${result.trimEnd()}\n${key}=${value}\n`;
28
+ }
29
+ return result;
30
+ }
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) => {
35
+ try {
36
+ return execFileSync(cmd, args, {
37
+ cwd,
38
+ encoding: "utf8",
39
+ stdio: [
40
+ "ignore",
41
+ "pipe",
42
+ "ignore"
43
+ ]
44
+ }).trim();
45
+ } catch {
46
+ return;
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];
52
+ }
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;
61
+ }
62
+ return true;
63
+ }
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", [
100
+ "version",
101
+ "--format",
102
+ "{{.Server.Version}}"
103
+ ]);
104
+ const checks = [{
105
+ name: "Docker is running",
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", [
111
+ "compose",
112
+ "version",
113
+ "--short"
114
+ ]);
115
+ checks.push({
116
+ name: "Docker Compose v2",
117
+ ok: Boolean(compose),
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."]
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());
178
+ const envPath = join(directory, ".env");
179
+ if (!existsSync(envPath)) {
180
+ checks.push({
181
+ name: ".env exists",
182
+ ok: false,
183
+ detail: "Run the doctor in the project directory, or copy .env.example to .env."
184
+ });
185
+ return checks;
186
+ }
187
+ const env = parseEnv(readFileSync(envPath, "utf8"));
188
+ const missing = REQUIRED_KEYS.filter((key) => !env[key]);
189
+ checks.push({
190
+ name: ".env has the required values",
191
+ ok: missing.length === 0,
192
+ detail: missing.length ? `Missing: ${missing.join(", ")}` : void 0
193
+ });
194
+ checks.push({
195
+ name: "The API keys differ",
196
+ ok: Boolean(env.API_SECRET_KEY) && env.API_SECRET_KEY !== env.API_READ_KEY,
197
+ detail: "API_SECRET_KEY has full access and API_READ_KEY only reads; they must not be the same."
198
+ });
199
+ if ([
200
+ "1",
201
+ "true",
202
+ "yes",
203
+ "on"
204
+ ].includes((env.DJANGO_DEBUG ?? "").toLowerCase()) && env.SITE_URL?.startsWith("https://")) checks.push({
205
+ name: "Debug is off for an https site",
206
+ ok: false,
207
+ detail: "Set DJANGO_DEBUG=0 in production."
208
+ });
209
+ const prod = (env.COMPOSE_PROFILES ?? "").split(",").map((profile) => profile.trim()).includes("prod");
210
+ if (prod) {
211
+ checks.push({
212
+ name: "SITE_URL is https",
213
+ ok: (env.SITE_URL ?? "").startsWith("https://"),
214
+ detail: "In production SITE_URL is https://your-domain; Caddy gets the certificate for it."
215
+ });
216
+ checks.push({
217
+ name: "Mail goes to an SMTP server",
218
+ ok: Boolean(env.EMAIL_URL) && !env.EMAIL_URL?.includes("mailpit") && !env.EMAIL_URL?.includes("example.com"),
219
+ detail: "Set EMAIL_URL to your SMTP server; Mailpit only runs in development."
220
+ });
221
+ }
222
+ const running = run("docker", [
223
+ "compose",
224
+ "ps",
225
+ "--services",
226
+ "--filter",
227
+ "status=running"
228
+ ], directory);
229
+ const services = running ? running.split("\n").filter(Boolean) : [];
230
+ const backendService = prod ? "backend-prod" : "backend";
231
+ const frontendService = prod ? "frontend-prod" : "frontend";
232
+ checks.push({
233
+ name: "Services are running",
234
+ ok: services.includes(backendService) && services.includes(frontendService),
235
+ warning: services.length === 0,
236
+ detail: services.length ? services.join(", ") : `Nothing is running. Start the project with \`docker compose up${prod ? " -d" : ""}\`.`
237
+ });
238
+ if (services.length === 0) return checks;
239
+ const health = run("docker", [
240
+ "compose",
241
+ "ps",
242
+ backendService,
243
+ "--format",
244
+ "{{.Health}}"
245
+ ], directory);
246
+ checks.push({
247
+ name: "The backend is healthy",
248
+ ok: health === "healthy",
249
+ detail: health ? `The ${backendService} container is ${health}` : `See \`docker compose logs ${backendService}\`.`
250
+ });
251
+ const site = (env.SITE_URL ?? "http://localhost:3000").replace(/\/$/, "");
252
+ const frontend = await fetchStatus(site);
253
+ checks.push({
254
+ name: "The site answers",
255
+ ok: frontend !== void 0 && frontend < 500,
256
+ detail: frontend ? `${site} answered ${frontend}` : `No answer on ${site}. See \`docker compose logs ${prod ? "proxy" : frontendService}\`.`
257
+ });
258
+ const api = await fetchStatus(`${site}/api/content.sitesettings`, { "X-Secret-Key": env.API_READ_KEY ?? "" });
259
+ checks.push({
260
+ name: "The read-only key reads the API",
261
+ ok: api === 200,
262
+ detail: api ? `${site}/api answered ${api}` : "No answer from the API."
263
+ });
264
+ const closed = await fetchStatus(`${site}/api/content.sitesettings`);
265
+ checks.push({
266
+ name: "The API is closed without a key",
267
+ ok: closed === 401 || closed === 403,
268
+ detail: closed ? `The API answered ${closed} without a key` : void 0
269
+ });
270
+ return checks;
271
+ }
272
+ // src/secrets.ts
273
+ /** A URL-safe random secret with `bytes` bytes of randomness. */
274
+ function generateSecret(bytes = 32) {
275
+ return randomBytes(bytes).toString("base64url");
276
+ }
277
+ /** The secrets a project's .env needs, by the name used in `{{secret:<name>}}`. */
278
+ function generateProjectSecrets() {
279
+ return {
280
+ django: generateSecret(48),
281
+ postgres: generateSecret(24),
282
+ api: generateSecret(32),
283
+ api_read: generateSecret(32),
284
+ preview: generateSecret(32),
285
+ revalidate: generateSecret(32),
286
+ admin: generateSecret(12)
287
+ };
288
+ }
289
+ // src/production.ts
290
+ function validateDomain(domain) {
291
+ if (!/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i.test(domain)) return "Enter a domain name such as example.com, without https://.";
292
+ }
293
+ /**
294
+ * The .env for a server, from a project's development .env: the prod
295
+ * profile, HTTPS on the domain, and fresh secrets, so nothing from the
296
+ * laptop works on the server.
297
+ */
298
+ function productionEnv(devEnv, options) {
299
+ const domainError = validateDomain(options.domain);
300
+ if (domainError) throw new Error(domainError);
301
+ const current = parseEnv(devEnv);
302
+ const secrets = generateProjectSecrets();
303
+ const url = `https://${options.domain.toLowerCase()}`;
304
+ const todo = [];
305
+ const profiles = (current.COMPOSE_PROFILES ?? "dev,db").split(",").map((profile) => profile.trim()).filter((profile) => profile && profile !== "dev" && profile !== "s3");
306
+ profiles.unshift("prod");
307
+ const values = {
308
+ COMPOSE_PROFILES: [...new Set(profiles)].join(","),
309
+ SITE_URL: url,
310
+ BACKEND_URL: url,
311
+ DJANGO_DEBUG: "0",
312
+ DJANGO_SECRET_KEY: secrets.django ?? "",
313
+ API_SECRET_KEY: secrets.api ?? "",
314
+ API_READ_KEY: secrets.api_read ?? "",
315
+ PREVIEW_SECRET: secrets.preview ?? "",
316
+ REVALIDATE_SECRET: secrets.revalidate ?? "",
317
+ ADMIN_PASSWORD: secrets.admin ?? "",
318
+ DEFAULT_FROM_EMAIL: `${current.PROJECT_NAME ?? "site"} <noreply@${options.domain.toLowerCase()}>`
319
+ };
320
+ if (profiles.includes("db")) {
321
+ const user = current.POSTGRES_USER || "app";
322
+ const name = current.POSTGRES_DB || "app";
323
+ values.POSTGRES_PASSWORD = secrets.postgres ?? "";
324
+ values.DATABASE_URL = `postgres://${user}:${secrets.postgres}@db:5432/${name}`;
325
+ }
326
+ if (options.emailUrl) values.EMAIL_URL = options.emailUrl;
327
+ else if (!current.EMAIL_URL || current.EMAIL_URL.includes("mailpit")) {
328
+ values.EMAIL_URL = "smtp+tls://user:password@smtp.example.com:587";
329
+ todo.push("Set EMAIL_URL to your SMTP server, so password resets and notifications are sent.");
330
+ }
331
+ if ((current.S3_ENDPOINT_URL ?? "").includes("s3:8333")) {
332
+ Object.assign(values, {
333
+ S3_BUCKET: "",
334
+ S3_ENDPOINT_URL: "",
335
+ S3_REGION: "",
336
+ S3_ACCESS_KEY_ID: "",
337
+ S3_SECRET_ACCESS_KEY: "",
338
+ S3_PUBLIC_URL: "",
339
+ S3_CREATE_BUCKET: "0"
340
+ });
341
+ todo.push("Set the S3_* variables to your S3 service, or leave them empty to keep uploads on the server.");
342
+ }
343
+ if (!current.ADMIN_EMAIL || current.ADMIN_EMAIL.endsWith("@example.com")) todo.push("Set ADMIN_EMAIL to your address.");
344
+ if (current.SEED_DEMO_CONTENT === "1") todo.push("Set SEED_DEMO_CONTENT=0 unless the site should start with the demo pages and posts.");
345
+ return {
346
+ text: setEnvValues(devEnv, values),
347
+ adminPassword: values.ADMIN_PASSWORD ?? "",
348
+ todo
349
+ };
350
+ }
351
+ // src/version.ts
352
+ /** The version of this package, the same for every @django-stack package. */
353
+ const CLI_VERSION = createRequire(import.meta.url)("../package.json").version;
354
+ // src/scaffold.ts
355
+ const FRONTENDS = ["astro"];
356
+ /** The local S3 service of the `s3` profile. It accepts any credentials. */
357
+ const LOCAL_S3 = {
358
+ S3_BUCKET: "media",
359
+ S3_ENDPOINT_URL: "http://s3:8333",
360
+ S3_REGION: "us-east-1",
361
+ S3_ACCESS_KEY_ID: "local",
362
+ S3_SECRET_ACCESS_KEY: "local",
363
+ S3_PUBLIC_URL: "http://localhost:8333/media",
364
+ S3_CREATE_BUCKET: "1"
365
+ };
366
+ const TEXT_EXTENSIONS = /\.(py|ts|mjs|js|json|astro|css|md|yaml|yml|toml|txt|html|sh|svg)$|Dockerfile$|gitignore$|\.dockerignore$|env\.example$/;
367
+ function defaultTemplatesDir() {
368
+ return fileURLToPath(new URL("../templates", import.meta.url));
369
+ }
370
+ /** A valid project name from a directory name: `My Site` becomes `my-site`. */
371
+ function toProjectName(value) {
372
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50);
373
+ }
374
+ function validateProjectName(value) {
375
+ if (!/^[a-z][a-z0-9-]{0,49}$/.test(value)) return "Use lowercase letters, digits and dashes, starting with a letter.";
376
+ }
377
+ /** Postgres URLs for hosts other than this machine must use TLS. */
378
+ function normalizeDatabaseUrl(url) {
379
+ let parsed;
380
+ try {
381
+ parsed = new URL(url);
382
+ } catch {
383
+ throw new Error(`The database URL is not a URL: ${url}`);
384
+ }
385
+ if (!["postgres:", "postgresql:"].includes(parsed.protocol)) throw new Error("The database URL must start with postgres:// or postgresql://.");
386
+ if (![
387
+ "localhost",
388
+ "127.0.0.1",
389
+ "host.docker.internal",
390
+ "db"
391
+ ].includes(parsed.hostname) && !parsed.searchParams.has("sslmode")) parsed.searchParams.set("sslmode", "require");
392
+ return parsed.toString();
393
+ }
394
+ function walk(directory) {
395
+ return readdirSync(directory).flatMap((entry) => {
396
+ const path = join(directory, entry);
397
+ return statSync(path).isDirectory() ? walk(path) : [path];
398
+ });
399
+ }
400
+ function render(text, values) {
401
+ return text.replace(/\{\{([a-z_]+)\}\}/g, (match, key) => values[key] ?? match);
402
+ }
403
+ /** The IANA time zones Node knows, with UTC first. */
404
+ function timeZones() {
405
+ return ["UTC", ...Intl.supportedValuesOf("timeZone").filter((zone) => zone !== "UTC")];
406
+ }
407
+ function validateTimeZone(value) {
408
+ if (timeZones().includes(value)) return void 0;
409
+ return `${JSON.stringify(value)} is not a time zone. Use a name such as Europe/Amsterdam or UTC.`;
410
+ }
411
+ function scaffold(options) {
412
+ const templates = options.templatesDir ?? defaultTemplatesDir();
413
+ const target = options.directory;
414
+ if (existsSync(target) && readdirSync(target).length > 0) throw new Error(`${target} exists and is not empty. Choose another directory.`);
415
+ const nameError = validateProjectName(options.name);
416
+ if (nameError) throw new Error(`The project name ${JSON.stringify(options.name)} is not valid. ${nameError}`);
417
+ if (options.timeZone) {
418
+ const zoneError = validateTimeZone(options.timeZone);
419
+ if (zoneError) throw new Error(zoneError);
420
+ }
421
+ mkdirSync(target, { recursive: true });
422
+ cpSync(join(templates, "base"), target, { recursive: true });
423
+ cpSync(join(templates, "backend"), join(target, "backend"), { recursive: true });
424
+ cpSync(join(templates, `frontend-${options.frontend}`), join(target, "frontend"), { recursive: true });
425
+ renameSync(join(target, "gitignore"), join(target, ".gitignore"));
426
+ const values = {
427
+ project_name: options.name,
428
+ frontend: options.frontend,
429
+ cli_version: CLI_VERSION,
430
+ preset: options.preset ?? "a"
431
+ };
432
+ const files = walk(target);
433
+ for (const file of files) {
434
+ if (!TEXT_EXTENSIONS.test(file)) continue;
435
+ const text = readFileSync(file, "utf8");
436
+ const rendered = render(text, values);
437
+ if (rendered !== text) writeFileSync(file, rendered);
438
+ }
439
+ if (options.timeZone && options.timeZone !== "UTC") {
440
+ const settings = join(target, "backend", "config", "settings.py");
441
+ const text = readFileSync(settings, "utf8");
442
+ writeFileSync(settings, text.replace("TIME_ZONE = \"UTC\"", `TIME_ZONE = ${JSON.stringify(options.timeZone)}`));
443
+ }
444
+ const template = readFileSync(join(target, "env.example"), "utf8");
445
+ const secrets = generateProjectSecrets();
446
+ let env = template.replace(/\{\{secret:([a-z_]+)\}\}/g, (match, key) => secrets[key] ?? match);
447
+ const envExample = template.replace(/\{\{secret:[a-z_]+\}\}/g, "");
448
+ const profiles = ["dev"];
449
+ if (options.databaseUrl) env = setEnvValues(env, { DATABASE_URL: normalizeDatabaseUrl(options.databaseUrl) });
450
+ else profiles.push("db");
451
+ if (options.localS3) {
452
+ profiles.push("s3");
453
+ env = setEnvValues(env, LOCAL_S3);
454
+ }
455
+ env = setEnvValues(env, { COMPOSE_PROFILES: profiles.join(",") });
456
+ if (!options.demoContent) env = setEnvValues(env, { SEED_DEMO_CONTENT: "0" });
457
+ writeFileSync(join(target, ".env"), env, { mode: 384 });
458
+ writeFileSync(join(target, ".env.example"), envExample);
459
+ rmSync(join(target, "env.example"));
460
+ if (options.link) linkPackages(target, options.link);
461
+ return {
462
+ directory: target,
463
+ adminUsername: "admin",
464
+ adminPassword: secrets.admin ?? "",
465
+ files: walk(target).length
466
+ };
467
+ }
468
+ /**
469
+ * Development only: build django-stack and @django-stack/utils from a
470
+ * monorepo checkout into the project's vendor/ directories and point the
471
+ * dependencies at them, so a project can run before a release.
472
+ */
473
+ function linkPackages(target, monorepo) {
474
+ const backendVendor = join(target, "backend", "vendor");
475
+ const frontendVendor = join(target, "frontend", "vendor");
476
+ mkdirSync(backendVendor, { recursive: true });
477
+ mkdirSync(frontendVendor, { recursive: true });
478
+ execFileSync("uv", [
479
+ "build",
480
+ "--wheel",
481
+ "--out-dir",
482
+ backendVendor
483
+ ], {
484
+ cwd: join(monorepo, "python", "django-stack"),
485
+ stdio: "ignore"
486
+ });
487
+ rmSync(join(backendVendor, ".gitignore"), { force: true });
488
+ const wheel = readdirSync(backendVendor).find((file) => file.endsWith(".whl"));
489
+ if (!wheel) throw new Error("Building the django-stack wheel failed.");
490
+ const pyproject = join(target, "backend", "pyproject.toml");
491
+ writeFileSync(pyproject, `${readFileSync(pyproject, "utf8").trimEnd()}\n\n[tool.uv.sources]\ndjango-stack = { path = "vendor/${wheel}" }\n`);
492
+ execFileSync("pnpm", [
493
+ "--filter",
494
+ "@django-stack/utils",
495
+ "build"
496
+ ], {
497
+ cwd: monorepo,
498
+ stdio: "ignore"
499
+ });
500
+ execFileSync("pnpm", [
501
+ "pack",
502
+ "--pack-destination",
503
+ frontendVendor
504
+ ], {
505
+ cwd: join(monorepo, "packages", "utils"),
506
+ stdio: "ignore"
507
+ });
508
+ const tarball = readdirSync(frontendVendor).find((file) => file.endsWith(".tgz"));
509
+ if (!tarball) throw new Error("Packing @django-stack/utils failed.");
510
+ const packageJson = join(target, "frontend", "package.json");
511
+ const manifest = JSON.parse(readFileSync(packageJson, "utf8"));
512
+ manifest.dependencies["@django-stack/utils"] = `file:vendor/${tarball}`;
513
+ writeFileSync(packageJson, `${JSON.stringify(manifest, null, 2)}\n`);
514
+ }
515
+ /** Locks the backend's dependencies with uv, when uv is installed. */
516
+ function lockBackend(target) {
517
+ try {
518
+ execFileSync("uv", ["lock", "--quiet"], {
519
+ cwd: join(target, "backend"),
520
+ stdio: "ignore"
521
+ });
522
+ return true;
523
+ } catch {
524
+ return false;
525
+ }
526
+ }
527
+ function relativeDirectory(directory) {
528
+ const path = relative(process.cwd(), directory) || ".";
529
+ return path.startsWith("..") ? directory : path;
530
+ }
531
+ // src/stack.ts
532
+ const STACK_OPTIONS = [
533
+ {
534
+ key: "frontend",
535
+ flag: "--frontend",
536
+ message: "Frontend",
537
+ description: "The frontend framework",
538
+ values: [
539
+ {
540
+ value: "astro",
541
+ label: "Astro",
542
+ hint: "server-rendered, fast by default",
543
+ generated: true
544
+ },
545
+ {
546
+ value: "next",
547
+ label: "Next.js",
548
+ hint: "React, server components",
549
+ generated: false
550
+ },
551
+ {
552
+ value: "headless",
553
+ label: "Headless",
554
+ hint: "just the API and the admin",
555
+ generated: false
556
+ },
557
+ {
558
+ value: "templates",
559
+ label: "Django templates",
560
+ hint: "no Node, Django renders the site",
561
+ generated: false
562
+ }
563
+ ]
564
+ },
565
+ {
566
+ key: "postgres",
567
+ flag: "--postgres",
568
+ message: "Postgres version",
569
+ description: "The bundled Postgres version",
570
+ values: [{
571
+ value: "stable",
572
+ label: "Stable",
573
+ hint: "Postgres 17",
574
+ generated: true
575
+ }, {
576
+ value: "latest",
577
+ label: "Latest",
578
+ hint: "Postgres 18",
579
+ generated: false
580
+ }]
581
+ },
582
+ {
583
+ key: "analytics",
584
+ flag: "--analytics",
585
+ message: "Analytics",
586
+ description: "Page analytics",
587
+ values: [{
588
+ value: "umami",
589
+ label: "Umami",
590
+ hint: "built in, private, no cookies",
591
+ generated: false
592
+ }, {
593
+ value: "off",
594
+ label: "Off",
595
+ generated: true
596
+ }]
597
+ },
598
+ {
599
+ key: "tasks",
600
+ flag: "--tasks",
601
+ message: "Background tasks",
602
+ description: "The task queue",
603
+ values: [{
604
+ value: "huey",
605
+ label: "Huey",
606
+ hint: "a worker container with Valkey",
607
+ generated: true
608
+ }, {
609
+ value: "off",
610
+ label: "Off",
611
+ generated: false
612
+ }]
613
+ },
614
+ {
615
+ key: "cache",
616
+ flag: "--cache",
617
+ message: "Cache",
618
+ description: "The cache backend",
619
+ values: [
620
+ {
621
+ value: "database",
622
+ label: "Database",
623
+ hint: "a Postgres table, nothing extra to run",
624
+ generated: false
625
+ },
626
+ {
627
+ value: "valkey",
628
+ label: "Valkey",
629
+ hint: "in memory, Redis-compatible",
630
+ generated: false
631
+ },
632
+ {
633
+ value: "off",
634
+ label: "Off",
635
+ generated: false
636
+ }
637
+ ]
638
+ },
639
+ {
640
+ key: "notifications",
641
+ flag: "--notifications",
642
+ message: "Notifications",
643
+ description: "Notifications in the admin and by email",
644
+ values: [{
645
+ value: "on",
646
+ label: "On",
647
+ hint: "in the admin and by email",
648
+ generated: false
649
+ }, {
650
+ value: "off",
651
+ label: "Off",
652
+ generated: true
653
+ }]
654
+ },
655
+ {
656
+ key: "captcha",
657
+ flag: "--captcha",
658
+ message: "Captcha",
659
+ description: "The built-in captcha for forms",
660
+ values: [{
661
+ value: "on",
662
+ label: "On",
663
+ hint: "proof of work, no tracking",
664
+ generated: false
665
+ }, {
666
+ value: "off",
667
+ label: "Off",
668
+ generated: true
669
+ }]
670
+ },
671
+ {
672
+ key: "storage",
673
+ flag: "--storage",
674
+ message: "Uploads in development",
675
+ description: "Where uploads go in development",
676
+ values: [{
677
+ value: "local",
678
+ label: "Local files",
679
+ hint: "a Docker volume",
680
+ generated: true
681
+ }, {
682
+ value: "s3",
683
+ label: "Local S3",
684
+ hint: "the same code path as S3 in production",
685
+ generated: true
686
+ }]
687
+ },
688
+ {
689
+ key: "packageManager",
690
+ flag: "--package-manager",
691
+ message: "Package manager",
692
+ description: "The frontend's package manager",
693
+ values: [
694
+ {
695
+ value: "pnpm",
696
+ label: "pnpm",
697
+ generated: false
698
+ },
699
+ {
700
+ value: "npm",
701
+ label: "npm",
702
+ generated: true
703
+ },
704
+ {
705
+ value: "bun",
706
+ label: "Bun",
707
+ generated: false
708
+ }
709
+ ]
710
+ }
711
+ ];
712
+ function stackOption(key) {
713
+ const option = STACK_OPTIONS.find((candidate) => candidate.key === key);
714
+ if (!option) throw new Error(`Unknown stack option ${key}`);
715
+ return option;
716
+ }
717
+ const DEFAULT_STACK = Object.fromEntries(STACK_OPTIONS.map((option) => [option.key, option.values[0].value]));
718
+ /** Whether an option applies to a stack: the package manager only with a Node frontend. */
719
+ function applies(key, stack) {
720
+ if (key === "packageManager") return stack.frontend === void 0 || ["astro", "next"].includes(stack.frontend);
721
+ return true;
722
+ }
723
+ /** The preset format version, the first character of a code. */
724
+ const VERSION = "a";
725
+ const DIGITS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
726
+ /** A preset code for a stack: the version, then one character per option, trailing defaults left out. */
727
+ function encodePreset(stack) {
728
+ const digits = STACK_OPTIONS.map((option) => {
729
+ const index = option.values.findIndex((value) => value.value === stack[option.key]);
730
+ if (index < 0) throw new Error(`${JSON.stringify(stack[option.key])} is not a ${option.key}.`);
731
+ return DIGITS[index];
732
+ }).join("").replace(/0+$/, "");
733
+ return VERSION + digits;
734
+ }
735
+ const UPGRADE = "This preset needs a newer CLI. Run npx @django-stack/cli@latest init.";
736
+ /** The stack of a preset code. Throws with a one-line reason for a bad code. */
737
+ function decodePreset(code) {
738
+ if (!/^[0-9a-zA-Z]+$/.test(code)) throw new Error(`${JSON.stringify(code)} is not a preset.`);
739
+ if (code[0] !== VERSION) {
740
+ if (DIGITS.indexOf(code[0] ?? "") > DIGITS.indexOf(VERSION)) throw new Error(UPGRADE);
741
+ throw new Error(`${JSON.stringify(code)} is not a preset.`);
742
+ }
743
+ const digits = code.slice(1);
744
+ if (digits.length > STACK_OPTIONS.length) throw new Error(UPGRADE);
745
+ const stack = { ...DEFAULT_STACK };
746
+ [...digits].forEach((digit, position) => {
747
+ const option = STACK_OPTIONS[position];
748
+ const value = option.values[DIGITS.indexOf(digit)];
749
+ if (!value) throw new Error(UPGRADE);
750
+ stack[option.key] = value.value;
751
+ });
752
+ return stack;
753
+ }
754
+ const NAMED_PRESETS = {
755
+ blog: {
756
+ description: "Astro with everything on (the defaults)",
757
+ stack: {}
758
+ },
759
+ api: {
760
+ description: "Headless: the API and the admin, no analytics",
761
+ stack: {
762
+ frontend: "headless",
763
+ analytics: "off"
764
+ }
765
+ },
766
+ minimal: {
767
+ description: "Astro with every optional service off",
768
+ stack: {
769
+ analytics: "off",
770
+ tasks: "off",
771
+ cache: "off",
772
+ notifications: "off",
773
+ captcha: "off"
774
+ }
775
+ }
776
+ };
777
+ /** The stack of a preset name or code. */
778
+ function resolvePreset(input) {
779
+ const named = NAMED_PRESETS[input];
780
+ if (named) return {
781
+ ...DEFAULT_STACK,
782
+ ...named.stack
783
+ };
784
+ return decodePreset(input);
785
+ }
786
+ /** The choices this CLI version does not generate yet, as `--flag value`. */
787
+ function pendingChoices(stack) {
788
+ return STACK_OPTIONS.flatMap((option) => {
789
+ const key = option.key;
790
+ if (!applies(key, stack)) return [];
791
+ const value = option.values.find((candidate) => candidate.value === stack[key]);
792
+ if (!value || value.generated) return [];
793
+ return [{
794
+ key,
795
+ choice: `${option.flag} ${value.value}`,
796
+ isDefault: value === option.values[0]
797
+ }];
798
+ });
799
+ }
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 };