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