@django-stack/cli 0.10.0 → 0.11.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 (48) hide show
  1. package/dist/bin.js +38 -18
  2. package/dist/index.d.ts +11 -4
  3. package/dist/index.js +1 -1
  4. package/dist/{production-Ct_qoeXk.js → production-WThC1aS2.js} +115 -23
  5. package/package.json +1 -1
  6. package/templates/backend/Dockerfile +7 -2
  7. package/templates/backend/config/settings.py +12 -0
  8. package/templates/backend/config/urls.py +11 -0
  9. package/templates/backend/content/admin.py +14 -5
  10. package/templates/backend/pyproject.toml +1 -1
  11. package/templates/base/.github/workflows/deploy.yml +10 -1
  12. package/templates/base/AGENTS.md +78 -0
  13. package/templates/base/Caddyfile +24 -0
  14. package/templates/base/README.md +71 -3
  15. package/templates/base/SECURITY.md +2 -0
  16. package/templates/base/compose.yaml +21 -2
  17. package/templates/base/env.example +25 -0
  18. package/templates/frontend-astro/package.json +1 -1
  19. package/templates/frontend-next/package.json +3 -3
  20. package/templates/frontend-nuxt/package.json +2 -2
  21. package/templates/frontend-sveltekit/package.json +2 -2
  22. package/templates/frontend-templates/template.json +5 -0
  23. package/templates/frontend-templates/website/__init__.py +0 -0
  24. package/templates/frontend-templates/website/apps.py +6 -0
  25. package/templates/frontend-templates/website/context_processors.py +16 -0
  26. package/templates/frontend-templates/website/forms.py +20 -0
  27. package/templates/frontend-templates/website/static/website/favicon.svg +1 -0
  28. package/templates/frontend-templates/website/static/website/global.css +232 -0
  29. package/templates/frontend-templates/website/templates/website/404.html +10 -0
  30. package/templates/frontend-templates/website/templates/website/base.html +44 -0
  31. package/templates/frontend-templates/website/templates/website/blocks/call_to_action.html +5 -0
  32. package/templates/frontend-templates/website/templates/website/blocks/image.html +4 -0
  33. package/templates/frontend-templates/website/templates/website/blocks/quote.html +4 -0
  34. package/templates/frontend-templates/website/templates/website/blocks/text.html +2 -0
  35. package/templates/frontend-templates/website/templates/website/home.html +8 -0
  36. package/templates/frontend-templates/website/templates/website/includes/contact_form.html +21 -0
  37. package/templates/frontend-templates/website/templates/website/includes/post_card.html +9 -0
  38. package/templates/frontend-templates/website/templates/website/includes/post_list.html +11 -0
  39. package/templates/frontend-templates/website/templates/website/page.html +10 -0
  40. package/templates/frontend-templates/website/templates/website/post.html +20 -0
  41. package/templates/frontend-templates/website/templates/website/post_list.html +11 -0
  42. package/templates/frontend-templates/website/templates/website/robots.txt +5 -0
  43. package/templates/frontend-templates/website/templates/website/rss.xml +16 -0
  44. package/templates/frontend-templates/website/templates/website/sitemap.xml +8 -0
  45. package/templates/frontend-templates/website/tests/__init__.py +0 -0
  46. package/templates/frontend-templates/website/tests/test_site.py +102 -0
  47. package/templates/frontend-templates/website/urls.py +17 -0
  48. package/templates/frontend-templates/website/views.py +176 -0
package/dist/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { $ as envPorts, A as compose, B as NAMED_PRESETS, C as PACKAGE_NAME, D as RUN_DOMAIN, E as CAPTCHA_APP, F as projectUrls, H as PROMPT_ORDER, I as readLocalRun, J as resolvePreset, K as encodePreset, L as readProject, M as findProjectDirectory, N as parseProfiles, O as RUN_ENV, P as projectStack, Q as describeMove, R as runningServices, S as CLI_VERSION, T as generateSecret, U as STACK_OPTIONS, V as NODE_FRONTENDS, W as applies, X as DEV_PORTS, Y as stackOption, Z as chooseFreePorts, _ as umamiEnv, a as LOCAL_S3, at as isDockerDown, b as validateProjectName, c as localS3, ct as setEnvValues, et as portValues, f as relativeDirectory, g as toProjectName, h as timeZones, it as dockerRunning, j as composeOutput, k as activeProject, l as lockBackend, m as setNotificationsApp, n as productionEnv$1, nt as checkTools, o as cacheEnv, ot as parseEnv, p as scaffold, q as pendingChoices, r as validateDomain, rt as dockerNotRunning, s as frontendPackageManager, st as quoteEnvValue, t as missingS3, tt as runDoctor, u as lockFrontend, v as updateFrontendLock, w as generateProjectSecrets, x as validateTimeZone, y as usesValkey, z as DEFAULT_STACK } from "./production-Ct_qoeXk.js";
2
+ import { $ as envPorts, A as compose, B as NAMED_PRESETS, C as PACKAGE_NAME, D as RUN_DOMAIN, E as CAPTCHA_APP, F as projectUrls, H as PROMPT_ORDER, I as readLocalRun, J as resolvePreset, K as encodePreset, L as readProject, M as findProjectDirectory, N as parseProfiles, O as RUN_ENV, P as projectStack, Q as describeMove, R as runningServices, S as CLI_VERSION, T as generateSecret, U as STACK_OPTIONS, V as NODE_FRONTENDS, W as applies, X as DEV_PORTS, Y as stackOption, Z as chooseFreePorts, _ as umamiEnv, a as LOCAL_S3, at as isDockerDown, b as validateProjectName, c as localS3, ct as setEnvValues, et as portValues, f as relativeDirectory, g as toProjectName, h as timeZones, it as dockerRunning, j as composeOutput, k as activeProject, l as lockBackend, m as setNotificationsApp, n as productionEnv$1, nt as checkTools, o as cacheEnv, ot as parseEnv, p as scaffold, q as pendingChoices, r as validateDomain, rt as dockerNotRunning, s as frontendPackageManager, st as quoteEnvValue, t as missingS3, tt as runDoctor, u as lockFrontend, v as updateFrontendLock, w as generateProjectSecrets, x as validateTimeZone, y as usesValkey, z as DEFAULT_STACK } from "./production-WThC1aS2.js";
3
3
  import { createRequire } from "node:module";
4
4
  import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { basename, delimiter, dirname, join, posix, relative, resolve, sep } from "node:path";
@@ -3930,7 +3930,8 @@ function choices(key, stack = {}) {
3930
3930
  /**
3931
3931
  * The stack from, in order of precedence, the flags, the preset, the prompts
3932
3932
  * and the defaults. `askDatabase` asks for the Postgres version together with
3933
- * the external database choice; it returns the version, or a URL.
3933
+ * the external database choice; it returns the version, and the URL for
3934
+ * production when there is one.
3934
3935
  */
3935
3936
  async function chooseStack(options, interactive, askDatabase) {
3936
3937
  let preset;
@@ -3949,10 +3950,9 @@ async function chooseStack(options, interactive, askDatabase) {
3949
3950
  else if (!interactive || !applies(key, stack)) continue;
3950
3951
  else if (key === "postgres" && askDatabase && !options.databaseUrl) {
3951
3952
  const answer = await askDatabase(stack[key]);
3952
- if ("databaseUrl" in answer) databaseUrl = answer.databaseUrl;
3953
- else stack[key] = answer.postgres;
3954
- } else if (key === "postgres" && options.databaseUrl) continue;
3955
- else {
3953
+ if (answer.databaseUrl) databaseUrl = answer.databaseUrl;
3954
+ stack[key] = answer.postgres;
3955
+ } else {
3956
3956
  const initialValue = key === "storage" && stack.media === "s3" ? "s3" : stack[key];
3957
3957
  stack[key] = unwrap(await p.select({
3958
3958
  message: option.message,
@@ -4018,19 +4018,31 @@ async function init(directoryArgument, options) {
4018
4018
  label: `Bundled Postgres, ${value.label.toLowerCase()}`
4019
4019
  })), {
4020
4020
  value: "external",
4021
- label: "External Postgres",
4022
- hint: "a URL to your own server"
4021
+ label: "External Postgres in production",
4022
+ hint: "a URL to your server; development runs the bundled one"
4023
4023
  }]
4024
4024
  }));
4025
4025
  if (choice !== "external") return { postgres: choice };
4026
- return { databaseUrl: unwrap(await p.text({
4027
- message: "Postgres URL",
4026
+ const databaseUrl = unwrap(await p.text({
4027
+ message: "Postgres URL for production",
4028
4028
  placeholder: "postgres://user:password@host:5432/name",
4029
4029
  validate: (value) => value?.startsWith("postgres") ? void 0 : "Enter a postgres:// URL."
4030
- })) };
4030
+ }));
4031
+ return {
4032
+ postgres: unwrap(await p.select({
4033
+ message: "Its Postgres version",
4034
+ initialValue: initial,
4035
+ options: choices("postgres")
4036
+ })),
4037
+ databaseUrl
4038
+ };
4031
4039
  });
4032
4040
  const stack = chosen.stack;
4033
4041
  const databaseUrl = options.databaseUrl ?? chosen.databaseUrl;
4042
+ if (!applies("analytics", stack) && stack.analytics !== "off") {
4043
+ if (options.fromCli("analytics")) fail("--analytics umami needs a frontend: a headless project has no pages to count.");
4044
+ stack.analytics = "off";
4045
+ }
4034
4046
  const pending = pendingChoices(stack);
4035
4047
  const unavailable = pending.filter((item) => !item.isDefault);
4036
4048
  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.`);
@@ -4066,6 +4078,7 @@ async function init(directoryArgument, options) {
4066
4078
  frontend: stack.frontend,
4067
4079
  demoContent,
4068
4080
  databaseUrl,
4081
+ postgres: stack.postgres,
4069
4082
  localS3: stack.storage === "s3",
4070
4083
  analytics: stack.analytics === "umami",
4071
4084
  cache: stack.cache,
@@ -4127,7 +4140,7 @@ async function init(directoryArgument, options) {
4127
4140
  command(`cd ${relativeDirectory(target)}`),
4128
4141
  command(startCommand),
4129
4142
  "",
4130
- `${pc.bold("Site")} ${url(`http://localhost:${ports.ports.SITE_PORT}`)}`,
4143
+ stack.frontend === "headless" ? `${pc.bold("API")} ${url(`http://localhost:${ports.ports.SITE_PORT}/api/`)}` : `${pc.bold("Site")} ${url(`http://localhost:${ports.ports.SITE_PORT}`)}`,
4131
4144
  `${pc.bold("Admin")} ${url(`http://localhost:${ports.ports.SITE_PORT}/admin/`)}`,
4132
4145
  `${pc.bold("Mail")} ${url(`http://localhost:${ports.ports.MAIL_PORT}`)}`,
4133
4146
  "",
@@ -4541,6 +4554,7 @@ function applyStack(envText, env, from, to) {
4541
4554
  if (from[key] === to[key]) continue;
4542
4555
  const value = option.values.find((candidate) => candidate.value === to[key]);
4543
4556
  if (!value) throw new Error(`${JSON.stringify(to[key])} is not a ${option.key}.`);
4557
+ if (key === "postgres") throw new Error("--postgres can't change on an existing project: a new major version needs a backup and a restore. See the docs on backups.");
4544
4558
  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.`);
4545
4559
  if (key === "storage") {
4546
4560
  if (profiles.includes("prod")) throw new Error("--storage only changes development; this .env runs the prod profile.");
@@ -4639,7 +4653,8 @@ function ensureRunEnv(project) {
4639
4653
  if (!existsSync(path)) {
4640
4654
  const result = productionEnv$1(readFileSync(join(project.directory, ".env"), "utf8"), {
4641
4655
  domain: RUN_DOMAIN,
4642
- emailUrl: "console://"
4656
+ emailUrl: "console://",
4657
+ bundledDatabase: true
4643
4658
  });
4644
4659
  writeFileSync(path, result.text, { mode: 384 });
4645
4660
  p.log.success(`Wrote ${pc.bold(RUN_ENV)}: the prod profile at ${url(`https://${RUN_DOMAIN}`)}, with its own secrets and data.`);
@@ -4695,7 +4710,8 @@ async function dev(options) {
4695
4710
  intro("Develop");
4696
4711
  project = await freeDevPorts(project);
4697
4712
  urlsNote(project, "Starting");
4698
- p.log.message(pc.dim("Changes in backend/ and frontend/ reload. Press Ctrl+C to stop."));
4713
+ const reloads = project.services.frontend ? "backend/ and frontend/" : "backend/";
4714
+ p.log.message(pc.dim(`Changes in ${reloads} reload. Press Ctrl+C to stop.`));
4699
4715
  exitWith(compose(project, ["up", ...options.build ? ["--build"] : []]));
4700
4716
  }
4701
4717
  /** `dstack stop`: stop the containers of the project and of its local run; volumes and data stay. */
@@ -4844,6 +4860,10 @@ async function types(options) {
4844
4860
  "--file",
4845
4861
  "openapi.json"
4846
4862
  ], false)) !== 0) fail("The backend could not write its schema.");
4863
+ if (!project.services.frontend) {
4864
+ p.outro(`Wrote ${pc.bold("backend/openapi.json")}`);
4865
+ return;
4866
+ }
4847
4867
  const generate = [
4848
4868
  "npx",
4849
4869
  "headless",
@@ -4931,7 +4951,7 @@ function checkSteps(project, tests) {
4931
4951
  ],
4932
4952
  devOnly: true
4933
4953
  });
4934
- return steps.filter((step) => !(step.devOnly && project.prod));
4954
+ return steps.filter((step) => !(step.devOnly && project.prod) && (step.service !== "frontend" || project.services.frontend));
4935
4955
  }
4936
4956
  /** `dstack check`: the doctor's checks, Django's checks, the linters and, with --tests, the tests. */
4937
4957
  async function check(options) {
@@ -5029,8 +5049,8 @@ async function update(options) {
5029
5049
  ];
5030
5050
  if (compose(target, build) !== 0) fail("Building the images failed.");
5031
5051
  }
5032
- const volume = project.prod ? void 0 : nodeModulesVolume(project);
5033
- if (volume) {
5052
+ const volume = project.prod || !project.services.frontend ? void 0 : nodeModulesVolume(project);
5053
+ if (volume && project.services.frontend) {
5034
5054
  compose(project, [
5035
5055
  "rm",
5036
5056
  "--stop",
@@ -5313,7 +5333,7 @@ function createProgram() {
5313
5333
  styleArgumentText: (text) => pc.yellow(text),
5314
5334
  styleDescriptionText: (text) => text
5315
5335
  }).configureOutput({ outputError: (text, write) => write(pc.red(text)) });
5316
- 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, {
5336
+ 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>", "An external Postgres for production; development runs 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, {
5317
5337
  ...options,
5318
5338
  installPrompt: program.opts().installPrompt,
5319
5339
  fromCli: (option) => cmd.getOptionValueSource(option) === "cli"
package/dist/index.d.ts CHANGED
@@ -20,6 +20,8 @@ interface ProductionOptions {
20
20
  emailUrl?: string;
21
21
  /** Where uploads go: `s3` asks for the S3 settings (the `media` stack option). */
22
22
  media?: string;
23
+ /** Keep the bundled database even with PRODUCTION_DATABASE_URL: the local run on this machine. */
24
+ bundledDatabase?: boolean;
23
25
  }
24
26
  interface ProductionEnv {
25
27
  text: string;
@@ -54,12 +56,12 @@ export declare const STACK_OPTIONS: readonly [{
54
56
  readonly value: "headless";
55
57
  readonly label: "Headless";
56
58
  readonly hint: "just the API and the admin";
57
- readonly generated: false;
59
+ readonly generated: true;
58
60
  }, {
59
61
  readonly value: "templates";
60
62
  readonly label: "Django templates";
61
63
  readonly hint: "no Node, Django renders the site";
62
- readonly generated: false;
64
+ readonly generated: true;
63
65
  }, {
64
66
  readonly value: "nuxt";
65
67
  readonly label: "Nuxt";
@@ -86,7 +88,7 @@ export declare const STACK_OPTIONS: readonly [{
86
88
  readonly value: "latest";
87
89
  readonly label: "Latest";
88
90
  readonly hint: "Postgres 18";
89
- readonly generated: false;
91
+ readonly generated: true;
90
92
  }];
91
93
  }, {
92
94
  readonly key: "analytics";
@@ -290,8 +292,13 @@ interface ScaffoldOptions {
290
292
  frontend: Frontend;
291
293
  /** Demo pages and posts on the first start. */
292
294
  demoContent: boolean;
293
- /** An external Postgres URL; the bundled Postgres when not set. */
295
+ /**
296
+ * An external Postgres URL for production, saved as PRODUCTION_DATABASE_URL;
297
+ * development runs the bundled Postgres either way.
298
+ */
294
299
  databaseUrl?: string;
300
+ /** The bundled Postgres's version, and the backend's Postgres client; stable by default. */
301
+ postgres?: Stack["postgres"];
295
302
  /** Store uploads in the local S3 service in development, like S3 in production. */
296
303
  localS3?: boolean;
297
304
  /** Page analytics with the bundled Umami (the `analytics` profile). */
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export { z as DEFAULT_STACK, i as FRONTENDS, B as NAMED_PRESETS, U as STACK_OPTIONS, S as VERSION, o as cacheEnv, G as decodePreset, K as encodePreset, w as generateProjectSecrets, T as generateSecret, d as normalizeDatabaseUrl, ot as parseEnv, q as pendingChoices, n as productionEnv, J as resolvePreset, tt as runDoctor, p as scaffold, ct as setEnvValues, m as setNotificationsApp, g as toProjectName, y as usesValkey, r as validateDomain, b as validateProjectName } from "./production-Ct_qoeXk.js";
1
+ export { z as DEFAULT_STACK, i as FRONTENDS, B as NAMED_PRESETS, U as STACK_OPTIONS, S as VERSION, o as cacheEnv, G as decodePreset, K as encodePreset, w as generateProjectSecrets, T as generateSecret, d as normalizeDatabaseUrl, ot as parseEnv, q as pendingChoices, n as productionEnv, J as resolvePreset, tt as runDoctor, p as scaffold, ct as setEnvValues, m as setNotificationsApp, g as toProjectName, y as usesValkey, r as validateDomain, b as validateProjectName } from "./production-WThC1aS2.js";
@@ -258,9 +258,10 @@ async function runDoctor(directory) {
258
258
  const services = running ? running.split("\n").filter(Boolean) : [];
259
259
  const backendService = prod ? "backend-prod" : "backend";
260
260
  const frontendService = prod ? "frontend-prod" : "frontend";
261
+ const frontend = existsSync(join(directory, "frontend", "package.json"));
261
262
  checks.push({
262
263
  name: "Services are running",
263
- ok: services.includes(backendService) && services.includes(frontendService),
264
+ ok: services.includes(backendService) && (!frontend || services.includes(frontendService)),
264
265
  warning: services.length === 0,
265
266
  detail: services.length ? services.join(", ") : `Nothing is running. Start the project with \`docker compose up${prod ? " -d" : ""}\`.`
266
267
  });
@@ -278,11 +279,12 @@ async function runDoctor(directory) {
278
279
  detail: health ? `The ${backendService} container is ${health}` : `See \`docker compose logs ${backendService}\`.`
279
280
  });
280
281
  const site = (env.SITE_URL ?? "http://localhost:3000").replace(/\/$/, "");
281
- const frontend = await fetchStatus(site);
282
+ const status = await fetchStatus(site);
283
+ const logs = prod ? "proxy" : frontend ? frontendService : backendService;
282
284
  checks.push({
283
285
  name: "The site answers",
284
- ok: frontend !== void 0 && frontend < 500,
285
- detail: frontend ? `${site} answered ${frontend}` : `No answer on ${site}. See \`docker compose logs ${prod ? "proxy" : frontendService}\`.`
286
+ ok: status !== void 0 && status < 500,
287
+ detail: status ? `${site} answered ${status}` : `No answer on ${site}. See \`docker compose logs ${logs}\`.`
286
288
  });
287
289
  const api = await fetchStatus(`${site}/api/content.sitesettings`, { "X-Secret-Key": env.API_READ_KEY ?? "" });
288
290
  checks.push({
@@ -461,13 +463,13 @@ const STACK_OPTIONS = [
461
463
  value: "headless",
462
464
  label: "Headless",
463
465
  hint: "just the API and the admin",
464
- generated: false
466
+ generated: true
465
467
  },
466
468
  {
467
469
  value: "templates",
468
470
  label: "Django templates",
469
471
  hint: "no Node, Django renders the site",
470
- generated: false
472
+ generated: true
471
473
  },
472
474
  {
473
475
  value: "nuxt",
@@ -498,7 +500,7 @@ const STACK_OPTIONS = [
498
500
  value: "latest",
499
501
  label: "Latest",
500
502
  hint: "Postgres 18",
501
- generated: false
503
+ generated: true
502
504
  }]
503
505
  },
504
506
  {
@@ -726,9 +728,13 @@ const NODE_FRONTENDS = [
726
728
  "nuxt",
727
729
  "sveltekit"
728
730
  ];
729
- /** Whether an option applies to a stack: the package manager only with a Node frontend. */
731
+ /**
732
+ * Whether an option applies to a stack: the package manager only with a Node
733
+ * frontend, page analytics only with a frontend for its tracker.
734
+ */
730
735
  function applies(key, stack) {
731
736
  if (key === "packageManager") return stack.frontend === void 0 || NODE_FRONTENDS.includes(stack.frontend);
737
+ if (key === "analytics") return stack.frontend !== "headless";
732
738
  return true;
733
739
  }
734
740
  const DIGITS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
@@ -916,6 +922,10 @@ function projectName(directory) {
916
922
  } catch {}
917
923
  return basename(directory).toLowerCase();
918
924
  }
925
+ /** Whether the project has a frontend: a headless one has no `frontend/`. */
926
+ function hasFrontend(directory) {
927
+ return existsSync(join(directory, "frontend", "package.json"));
928
+ }
919
929
  function fromEnv(directory, name, envFile, localRun) {
920
930
  const env = parseEnv(readFileSync(join(directory, envFile), "utf8"));
921
931
  const profiles = parseProfiles(env.COMPOSE_PROFILES);
@@ -930,7 +940,7 @@ function fromEnv(directory, name, envFile, localRun) {
930
940
  localRun,
931
941
  services: {
932
942
  backend: `backend${suffix}`,
933
- frontend: `frontend${suffix}`,
943
+ ...hasFrontend(directory) ? { frontend: `frontend${suffix}` } : {},
934
944
  worker: `worker${suffix}`
935
945
  },
936
946
  composeArgs: localRun ? [
@@ -1161,6 +1171,23 @@ const PACKAGE_NAME = "@django-stack/cli";
1161
1171
  // src/scaffold.ts
1162
1172
  /** The frontends this version of the CLI generates. */
1163
1173
  const FRONTENDS = stackOption("frontend").values.filter((value) => value.generated).map((value) => value.value);
1174
+ /** The Postgres major version of each `--postgres` value. */
1175
+ const POSTGRES_VERSIONS = {
1176
+ stable: "17",
1177
+ latest: "18"
1178
+ };
1179
+ /**
1180
+ * The template values of a Postgres version. From 18 the image keeps its data
1181
+ * in a versioned directory under /var/lib/postgresql, so the volume is
1182
+ * mounted there; before 18 at /var/lib/postgresql/data.
1183
+ */
1184
+ function postgresValues(postgres) {
1185
+ const version = POSTGRES_VERSIONS[postgres];
1186
+ return {
1187
+ postgres_version: version,
1188
+ postgres_data: Number(version) >= 18 ? "/var/lib/postgresql" : "/var/lib/postgresql/data"
1189
+ };
1190
+ }
1164
1191
  /** The pnpm and Bun versions the frontend image installs with. */
1165
1192
  const PNPM_VERSION = "12.18.0";
1166
1193
  const BUN_VERSION = "1.4.4";
@@ -1289,7 +1316,7 @@ function localS3(port = 8333) {
1289
1316
  };
1290
1317
  }
1291
1318
  const LOCAL_S3 = localS3();
1292
- const TEXT_EXTENSIONS = /\.(py|ts|tsx|mjs|js|jsx|json|astro|vue|svelte|css|md|yaml|yml|toml|txt|html|sh|svg)$|Dockerfile$|gitignore$|\.dockerignore$|env\.example$/;
1319
+ const TEXT_EXTENSIONS = /\.(py|ts|tsx|mjs|js|jsx|json|astro|vue|svelte|css|md|yaml|yml|toml|txt|html|sh|svg)$|Dockerfile$|Caddyfile$|gitignore$|\.dockerignore$|env\.example$/;
1293
1320
  function defaultTemplatesDir() {
1294
1321
  return fileURLToPath(new URL("../templates", import.meta.url));
1295
1322
  }
@@ -1323,6 +1350,35 @@ function walk(directory) {
1323
1350
  return statSync(path).isDirectory() ? walk(path) : [path];
1324
1351
  });
1325
1352
  }
1353
+ const BLOCK_START = /^\s*(?:#|<!--)\s*@if\s+(!?)([a-z]+)\s*(?:-->)?\s*$/;
1354
+ const BLOCK_END = /^\s*(?:#|<!--)\s*@end\s*(?:-->)?\s*$/;
1355
+ /**
1356
+ * Keeps or drops the template's conditional blocks: the lines between a
1357
+ * comment line `# @if frontend` (or `<!-- @if !frontend -->`) and `# @end`
1358
+ * stay when the flag is on (off with `!`). The marker lines always go.
1359
+ */
1360
+ function selectBlocks(text, flags) {
1361
+ if (!/@(if|end)\b/.test(text)) return text;
1362
+ const kept = [];
1363
+ const open = [];
1364
+ for (const line of text.split("\n")) {
1365
+ const start = line.match(BLOCK_START);
1366
+ if (start) {
1367
+ const flag = flags[start[2]];
1368
+ if (flag === void 0) throw new Error(`Unknown template flag ${start[2]}.`);
1369
+ open.push(start[1] ? !flag : flag);
1370
+ continue;
1371
+ }
1372
+ if (BLOCK_END.test(line)) {
1373
+ if (!open.length) throw new Error("@end without @if.");
1374
+ open.pop();
1375
+ continue;
1376
+ }
1377
+ if (open.every(Boolean)) kept.push(line);
1378
+ }
1379
+ if (open.length) throw new Error("@if without @end.");
1380
+ return kept.join("\n");
1381
+ }
1326
1382
  function render(text, values) {
1327
1383
  return text.replace(/\{\{([a-z_]+)\}\}/g, (match, key) => values[key] ?? match);
1328
1384
  }
@@ -1334,6 +1390,16 @@ function validateTimeZone(value) {
1334
1390
  if (timeZones().includes(value)) return void 0;
1335
1391
  return `${JSON.stringify(value)} is not a time zone. Use a name such as Europe/Amsterdam or UTC.`;
1336
1392
  }
1393
+ /** The root package.json of a headless project: the scripts without the frontend's steps. */
1394
+ function headlessScripts(text) {
1395
+ const manifest = JSON.parse(text);
1396
+ const scripts = manifest.scripts;
1397
+ scripts.test = "docker compose exec backend pytest";
1398
+ scripts.lint = "docker compose exec backend ruff check .";
1399
+ scripts["generate:types"] = (scripts["generate:types"] ?? "").split(" && ")[0] ?? "";
1400
+ manifest.description = manifest.description.replace(", Content Studio and Headless,", " and Content Studio,");
1401
+ return `${JSON.stringify(manifest, null, 2)}\n`;
1402
+ }
1337
1403
  function scaffold(options) {
1338
1404
  const templates = options.templatesDir ?? defaultTemplatesDir();
1339
1405
  const target = options.directory;
@@ -1344,17 +1410,28 @@ function scaffold(options) {
1344
1410
  const zoneError = validateTimeZone(options.timeZone);
1345
1411
  if (zoneError) throw new Error(zoneError);
1346
1412
  }
1413
+ const headless = options.frontend === "headless";
1414
+ const djangoTemplates = options.frontend === "templates";
1415
+ const jsFrontend = !headless && !djangoTemplates;
1347
1416
  const frontendTemplate = join(templates, `frontend-${options.frontend}`);
1348
- if (!existsSync(frontendTemplate)) throw new Error(`This version of the CLI has no ${options.frontend} frontend.`);
1417
+ if (!headless && !existsSync(frontendTemplate)) throw new Error(`This version of the CLI has no ${options.frontend} frontend.`);
1349
1418
  mkdirSync(target, { recursive: true });
1350
1419
  cpSync(join(templates, "base"), target, { recursive: true });
1351
1420
  cpSync(join(templates, "backend"), join(target, "backend"), { recursive: true });
1352
- cpSync(frontendTemplate, join(target, "frontend"), { recursive: true });
1421
+ const frontendDir = join(target, djangoTemplates ? "backend" : "frontend");
1422
+ if (!headless) cpSync(frontendTemplate, frontendDir, { recursive: true });
1353
1423
  renameSync(join(target, "gitignore"), join(target, ".gitignore"));
1354
1424
  makeExecutable(target);
1355
- const about = join(target, "frontend", "template.json");
1356
- const frontend = JSON.parse(readFileSync(about, "utf8"));
1357
- rmSync(about);
1425
+ let frontend = {
1426
+ label: "Headless",
1427
+ url: "",
1428
+ security: ""
1429
+ };
1430
+ if (!headless) {
1431
+ const about = join(frontendDir, "template.json");
1432
+ frontend = JSON.parse(readFileSync(about, "utf8"));
1433
+ rmSync(about);
1434
+ }
1358
1435
  const packageManager = options.packageManager ?? "pnpm";
1359
1436
  const values = {
1360
1437
  project_name: options.name,
@@ -1366,17 +1443,24 @@ function scaffold(options) {
1366
1443
  preset: options.preset ?? encodePreset(DEFAULT_STACK),
1367
1444
  site_port: String(options.ports?.SITE_PORT ?? 3e3),
1368
1445
  mail_port: String(options.ports?.MAIL_PORT ?? 8025),
1369
- ...packageManagerValues(packageManager)
1446
+ ...packageManagerValues(packageManager),
1447
+ ...postgresValues(options.postgres ?? DEFAULT_STACK.postgres)
1370
1448
  };
1371
1449
  const files = walk(target);
1372
1450
  for (const file of files) {
1373
1451
  if (!TEXT_EXTENSIONS.test(file)) continue;
1374
1452
  const text = readFileSync(file, "utf8");
1375
- const rendered = render(text, values);
1453
+ const rendered = render(selectBlocks(text, {
1454
+ frontend: jsFrontend,
1455
+ headless,
1456
+ templates: djangoTemplates,
1457
+ site: !headless
1458
+ }), values);
1376
1459
  if (rendered !== text) writeFileSync(file, rendered);
1377
1460
  }
1461
+ if (!jsFrontend) writeFileSync(join(target, "package.json"), headlessScripts(readFileSync(join(target, "package.json"), "utf8")));
1378
1462
  if (packageManager !== "pnpm") rmSync(join(target, "frontend", "pnpm-workspace.yaml"), { force: true });
1379
- if (packageManager !== "npm") {
1463
+ if (jsFrontend && packageManager !== "npm") {
1380
1464
  const packageJson = join(target, "frontend", "package.json");
1381
1465
  const manifest = JSON.parse(readFileSync(packageJson, "utf8"));
1382
1466
  const version = packageManager === "pnpm" ? PNPM_VERSION : BUN_VERSION;
@@ -1398,14 +1482,13 @@ function scaffold(options) {
1398
1482
  const secrets = generateProjectSecrets();
1399
1483
  let env = template.replace(/\{\{secret:([a-z_]+)\}\}/g, (match, key) => secrets[key] ?? match);
1400
1484
  const envExample = template.replace(/\{\{secret:[a-z_]+\}\}/g, "");
1401
- const profiles = ["dev"];
1402
- if (options.databaseUrl) env = setEnvValues(env, { DATABASE_URL: normalizeDatabaseUrl(options.databaseUrl) });
1403
- else profiles.push("db");
1485
+ const profiles = ["dev", "db"];
1486
+ if (options.databaseUrl) env = setEnvValues(env, { PRODUCTION_DATABASE_URL: normalizeDatabaseUrl(options.databaseUrl) });
1404
1487
  if (options.localS3) {
1405
1488
  profiles.push("s3");
1406
1489
  env = setEnvValues(env, LOCAL_S3);
1407
1490
  }
1408
- if (options.analytics) {
1491
+ if (options.analytics && !headless) {
1409
1492
  profiles.push("analytics");
1410
1493
  env = setEnvValues(env, umamiEnv(parseEnv(env).DATABASE_URL ?? "", secrets));
1411
1494
  }
@@ -1456,6 +1539,10 @@ function linkPackages(target, monorepo) {
1456
1539
  const pyproject = join(target, "backend", "pyproject.toml");
1457
1540
  writeFileSync(pyproject, `${readFileSync(pyproject, "utf8").trimEnd()}\n\n[tool.uv.sources]\ndjango-stack = { path = "vendor/${wheel}" }\n`);
1458
1541
  const packageJson = join(target, "frontend", "package.json");
1542
+ if (!existsSync(packageJson)) {
1543
+ rmSync(frontendVendor, { recursive: true });
1544
+ return;
1545
+ }
1459
1546
  const manifest = JSON.parse(readFileSync(packageJson, "utf8"));
1460
1547
  const packages = Object.keys(manifest.dependencies ?? {}).filter((name) => name.startsWith("@django-stack/"));
1461
1548
  for (const name of packages) {
@@ -1607,7 +1694,12 @@ function productionEnv(devEnv, options) {
1607
1694
  ADMIN_PASSWORD: secrets.admin ?? "",
1608
1695
  DEFAULT_FROM_EMAIL: `${current.PROJECT_NAME ?? "site"} <noreply@${options.domain.toLowerCase()}>`
1609
1696
  };
1610
- if (profiles.includes("db")) {
1697
+ const external = options.bundledDatabase ? "" : current.PRODUCTION_DATABASE_URL ?? "";
1698
+ if (external) {
1699
+ values.DATABASE_URL = external;
1700
+ values.POSTGRES_PASSWORD = "";
1701
+ values.COMPOSE_PROFILES = [...new Set(profiles.filter((profile) => profile !== "db"))].join(",");
1702
+ } else if (profiles.includes("db")) {
1611
1703
  const user = current.POSTGRES_USER || "app";
1612
1704
  const name = current.POSTGRES_DB || "app";
1613
1705
  values.POSTGRES_PASSWORD = secrets.postgres ?? "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@django-stack/cli",
3
- "version": "0.10.0",
3
+ "version": "0.11.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",
@@ -10,9 +10,14 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
10
10
  UV_PROJECT_ENVIRONMENT=/venv \
11
11
  PATH="/venv/bin:$PATH"
12
12
  COPY --from=ghcr.io/astral-sh/uv:0.11 /uv /bin/uv
13
- # The Postgres client, for the backups (pg_dump and pg_restore).
13
+ # The Postgres client, for the backups (pg_dump and pg_restore), from the
14
+ # PostgreSQL apt repository: its major version must match the server's, as
15
+ # pg_dump refuses a newer server. Change it with the database.
16
+ ARG POSTGRES_VERSION={{postgres_version}}
14
17
  RUN apt-get update \
15
- && apt-get install --yes --no-install-recommends postgresql-client \
18
+ && apt-get install --yes --no-install-recommends postgresql-common \
19
+ && /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y \
20
+ && apt-get install --yes --no-install-recommends "postgresql-client-${POSTGRES_VERSION}" \
16
21
  && rm -rf /var/lib/apt/lists/*
17
22
  RUN groupadd --system app && useradd --system --gid app --home-dir /app app
18
23
  WORKDIR /app
@@ -42,6 +42,10 @@ INSTALLED_APPS = [
42
42
  "django_stack",
43
43
  "django_stack.contrib.captcha", # the captcha of the contact form (CAPTCHA=off turns it off)
44
44
  "content",
45
+ # @if templates
46
+ "django_stack.contrib.site", # preview, the page cache, redirects and the template tags of the site
47
+ "website", # the site's pages
48
+ # @end
45
49
  # Last: builds the API routes once every model is exposed.
46
50
  "headless.rest",
47
51
  ]
@@ -56,6 +60,10 @@ MIDDLEWARE = [
56
60
  "django.contrib.auth.middleware.AuthenticationMiddleware",
57
61
  "django.contrib.messages.middleware.MessageMiddleware",
58
62
  "django.middleware.clickjacking.XFrameOptionsMiddleware",
63
+ # @if templates
64
+ # The redirects editors manage in the admin, for paths that do not exist.
65
+ "django_stack.contrib.site.middleware.RedirectMiddleware",
66
+ # @end
59
67
  ]
60
68
 
61
69
  ROOT_URLCONF = "config.urls"
@@ -72,6 +80,10 @@ TEMPLATES = [
72
80
  "django.template.context_processors.request",
73
81
  "django.contrib.auth.context_processors.auth",
74
82
  "django.contrib.messages.context_processors.messages",
83
+ # @if templates
84
+ "django_stack.contrib.site.context_processors.site", # preview, analytics_website_id
85
+ "website.context_processors.site", # site, main_menu, footer_menu
86
+ # @end
75
87
  ],
76
88
  },
77
89
  },
@@ -6,4 +6,15 @@ urlpatterns = [
6
6
  path("api/", include("headless.rest.urls")),
7
7
  path("", include("django_stack.urls")), # /healthz and /readyz
8
8
  *media_urlpatterns(), # uploaded files, in development only
9
+ # @if headless
10
+ path("", include("django_stack.start")), # a start page with links to the admin and the API schema
11
+ # @end
12
+ # @if templates
13
+ path("", include("website.urls")), # the site's pages, last: they include a catch-all for page paths
14
+ # @end
9
15
  ]
16
+ # @if templates
17
+
18
+ # The site's 404 page, with its layout (when DJANGO_DEBUG is off).
19
+ handler404 = "website.views.not_found"
20
+ # @end
@@ -7,6 +7,7 @@ from content_studio import register
7
7
  from content_studio.admin import ModelStudio
8
8
  from content_studio.analytics import Analytics
9
9
  from content_studio.form import ActivityLog, Field, FormSet
10
+ from django_stack.analytics import UmamiProvider
10
11
  from django_stack.studio import PreviewButton
11
12
 
12
13
  from .models import Author, Category, FormSubmission, Menu, Page, Post, Redirect, SiteSettings, Tag
@@ -17,9 +18,17 @@ POST_PREVIEW = PreviewButton(component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a02"
17
18
  PAGE_ACTIVITY = ActivityLog(component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a03")
18
19
  POST_ACTIVITY = ActivityLog(component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a04")
19
20
  # The visitors of the page or post, from the analytics (see config/studio.py).
20
- # Hidden while analytics are off.
21
- PAGE_ANALYTICS = Analytics(path_field="path", component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a05")
22
- POST_ANALYTICS = Analytics(path_field="slug", path_prefix="/blog/", component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a06")
21
+ # Only with analytics on: Content Studio refuses them without its
22
+ # AnalyticsExtension.
23
+ ANALYTICS = UmamiProvider.is_configured()
24
+ PAGE_ANALYTICS = (
25
+ [Analytics(path_field="path", component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a05")] if ANALYTICS else []
26
+ )
27
+ POST_ANALYTICS = (
28
+ [Analytics(path_field="slug", path_prefix="/blog/", component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a06")]
29
+ if ANALYTICS
30
+ else []
31
+ )
23
32
  SEO = FormSet(
24
33
  title="Search and sharing",
25
34
  description="Leave empty to use the title and the site settings.",
@@ -42,7 +51,7 @@ class PageStudio(ModelStudio):
42
51
  FormSet(title="Body", fields=["body"]),
43
52
  SEO,
44
53
  ]
45
- edit_sidebar = ["status", "published_at", PAGE_PREVIEW, "order", PAGE_ANALYTICS, PAGE_ACTIVITY]
54
+ edit_sidebar = ["status", "published_at", PAGE_PREVIEW, "order", *PAGE_ANALYTICS, PAGE_ACTIVITY]
46
55
 
47
56
 
48
57
  @register(Post)
@@ -67,7 +76,7 @@ class PostStudio(ModelStudio):
67
76
  "author",
68
77
  "categories",
69
78
  "tags",
70
- POST_ANALYTICS,
79
+ *POST_ANALYTICS,
71
80
  POST_ACTIVITY,
72
81
  ]
73
82
 
@@ -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.10.0",
8
+ "django-stack[server,s3]>=0.11.0",
9
9
  "django-headless[schema]>=1.1.1",
10
10
  "django-content-studio[images,huey]>=1.11.0",
11
11
  ]
@@ -24,7 +24,9 @@ permissions:
24
24
 
25
25
  env:
26
26
  BACKEND_IMAGE: ghcr.io/${{ github.repository }}-backend:${{ github.sha }}
27
+ # @if frontend
27
28
  FRONTEND_IMAGE: ghcr.io/${{ github.repository }}-frontend:${{ github.sha }}
29
+ # @end
28
30
 
29
31
  jobs:
30
32
  deploy:
@@ -38,9 +40,11 @@ jobs:
38
40
  - name: Build and push the images
39
41
  run: |
40
42
  docker build --target prod --tag "${BACKEND_IMAGE,,}" backend
41
- docker build --target prod --tag "${FRONTEND_IMAGE,,}" frontend
42
43
  docker push "${BACKEND_IMAGE,,}"
44
+ # @if frontend
45
+ docker build --target prod --tag "${FRONTEND_IMAGE,,}" frontend
43
46
  docker push "${FRONTEND_IMAGE,,}"
47
+ # @end
44
48
 
45
49
  - name: Set up SSH
46
50
  run: |
@@ -63,7 +67,12 @@ jobs:
63
67
  set -e
64
68
  cd '$DEPLOY_PATH'
65
69
  echo '$REGISTRY_TOKEN' | docker login ghcr.io --username '${{ github.actor }}' --password-stdin
70
+ # @if frontend
66
71
  export BACKEND_IMAGE='${BACKEND_IMAGE,,}' FRONTEND_IMAGE='${FRONTEND_IMAGE,,}'
72
+ # @end
73
+ # @if !frontend
74
+ export BACKEND_IMAGE='${BACKEND_IMAGE,,}'
75
+ # @end
67
76
  docker compose pull --quiet --ignore-buildable
68
77
  docker compose up -d --no-build --remove-orphans
69
78
  docker logout ghcr.io