@django-stack/cli 0.9.0 → 0.10.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 (45) hide show
  1. package/dist/bin.js +28 -3
  2. package/dist/index.d.ts +13 -5
  3. package/dist/index.js +1 -1
  4. package/dist/{production-M2SsuuBb.js → production-Ct_qoeXk.js} +154 -9
  5. package/package.json +1 -1
  6. package/templates/backend/config/settings.py +1 -0
  7. package/templates/backend/content/models.py +2 -2
  8. package/templates/backend/content/tests/test_api.py +15 -3
  9. package/templates/backend/pyproject.toml +1 -1
  10. package/templates/base/AGENTS.md +8 -8
  11. package/templates/base/README.md +3 -3
  12. package/templates/base/compose.yaml +7 -0
  13. package/templates/base/env.example +12 -0
  14. package/templates/frontend-astro/AGENTS.md +3 -3
  15. package/templates/frontend-astro/Dockerfile +6 -3
  16. package/templates/frontend-astro/astro.config.mjs +16 -5
  17. package/templates/frontend-astro/package.json +1 -1
  18. package/templates/frontend-astro/pnpm-workspace.yaml +17 -0
  19. package/templates/frontend-astro/src/components/ContactForm.astro +19 -3
  20. package/templates/frontend-astro/src/middleware.ts +9 -1
  21. package/templates/frontend-next/AGENTS.md +3 -3
  22. package/templates/frontend-next/Dockerfile +5 -2
  23. package/templates/frontend-next/package.json +3 -3
  24. package/templates/frontend-next/pnpm-workspace.yaml +11 -0
  25. package/templates/frontend-next/src/app/[...path]/page.tsx +2 -1
  26. package/templates/frontend-next/src/components/ContactForm.tsx +14 -3
  27. package/templates/frontend-next/src/instrumentation.ts +12 -0
  28. package/templates/frontend-next/src/proxy.ts +4 -1
  29. package/templates/frontend-nuxt/AGENTS.md +3 -3
  30. package/templates/frontend-nuxt/Dockerfile +5 -2
  31. package/templates/frontend-nuxt/nuxt.config.ts +7 -3
  32. package/templates/frontend-nuxt/package.json +2 -2
  33. package/templates/frontend-nuxt/pnpm-workspace.yaml +13 -0
  34. package/templates/frontend-nuxt/server/plugins/errors.ts +14 -0
  35. package/templates/frontend-nuxt/server/routes/_content/layout.get.ts +4 -0
  36. package/templates/frontend-nuxt/src/components/ContactForm.vue +15 -2
  37. package/templates/frontend-sveltekit/AGENTS.md +3 -3
  38. package/templates/frontend-sveltekit/Dockerfile +6 -3
  39. package/templates/frontend-sveltekit/package.json +2 -2
  40. package/templates/frontend-sveltekit/pnpm-workspace.yaml +11 -0
  41. package/templates/frontend-sveltekit/src/hooks.server.ts +13 -1
  42. package/templates/frontend-sveltekit/src/lib/components/ContactForm.svelte +15 -2
  43. package/templates/frontend-sveltekit/src/routes/+layout.server.ts +3 -0
  44. package/templates/frontend-sveltekit/src/routes/[...path]/+page.svelte +1 -1
  45. package/templates/frontend-sveltekit/vite.config.ts +10 -3
package/dist/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { $ as dockerRunning, A as projectStack, C as RUN_DOMAIN, D as composeOutput, E as compose, F as DEFAULT_STACK, G as DEV_PORTS, H as pendingChoices, I as NAMED_PRESETS, J as envPorts, K as chooseFreePorts, L as PROMPT_ORDER, M as readLocalRun, N as readProject, O as findProjectDirectory, P as runningServices, Q as dockerNotRunning, R as STACK_OPTIONS, S as generateSecret, T as activeProject, U as resolvePreset, V as encodePreset, W as stackOption, X as runDoctor, Y as portValues, Z as checkTools, _ as validateProjectName, a as LOCAL_S3, b as PACKAGE_NAME, c as lockBackend, d as scaffold, et as isDockerDown, f as setNotificationsApp, g as usesValkey, h as umamiEnv, j as projectUrls, k as parseProfiles, m as toProjectName, n as productionEnv$1, nt as quoteEnvValue, o as cacheEnv, p as timeZones, q as describeMove, r as validateDomain, rt as setEnvValues, s as localS3, t as missingS3, tt as parseEnv, u as relativeDirectory, v as validateTimeZone, w as RUN_ENV, x as generateProjectSecrets, y as CLI_VERSION, z as applies } from "./production-M2SsuuBb.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-Ct_qoeXk.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";
@@ -4070,6 +4070,8 @@ async function init(directoryArgument, options) {
4070
4070
  analytics: stack.analytics === "umami",
4071
4071
  cache: stack.cache,
4072
4072
  notifications: stack.notifications === "on",
4073
+ captcha: stack.captcha === "on",
4074
+ packageManager: stack.packageManager,
4073
4075
  errors: stack.errors,
4074
4076
  tasks: stack.tasks,
4075
4077
  preset: encodePreset(stack),
@@ -4087,6 +4089,16 @@ async function init(directoryArgument, options) {
4087
4089
  if (lockBackend(target)) spinner.stop(`Locked the backend dependencies in ${pc.bold("backend/uv.lock")}`);
4088
4090
  else spinner.error("Could not lock the backend dependencies. The image build resolves them instead.");
4089
4091
  }
4092
+ if (NODE_FRONTENDS.includes(stack.frontend)) {
4093
+ const lockfile = {
4094
+ pnpm: "pnpm-lock.yaml",
4095
+ npm: "package-lock.json",
4096
+ bun: "bun.lock"
4097
+ }[stack.packageManager];
4098
+ spinner.start(`Locking the frontend dependencies with ${stack.packageManager}`);
4099
+ if (lockFrontend(target, stack.packageManager)) spinner.stop(`Locked the frontend dependencies in ${pc.bold(`frontend/${lockfile}`)}`);
4100
+ else spinner.error("Could not lock the frontend dependencies. The image build resolves them instead.");
4101
+ }
4090
4102
  if (options.git && has("git")) try {
4091
4103
  execFileSync("git", ["init", "--quiet"], {
4092
4104
  cwd: target,
@@ -4512,7 +4524,8 @@ const SWITCHABLE = [
4512
4524
  "media",
4513
4525
  "cache",
4514
4526
  "tasks",
4515
- "notifications"
4527
+ "notifications",
4528
+ "captcha"
4516
4529
  ];
4517
4530
  const EMPTY_S3 = Object.fromEntries(Object.keys(LOCAL_S3).map((key) => [key, key === "S3_CREATE_BUCKET" ? "0" : ""]));
4518
4531
  /**
@@ -4580,6 +4593,10 @@ function applyStack(envText, env, from, to) {
4580
4593
  }
4581
4594
  }
4582
4595
  if (key === "notifications") changes.push(to.notifications === "on" ? "Notifications are on: content_studio.notifications is in INSTALLED_APPS." : "Notifications are off: content_studio.notifications is out of INSTALLED_APPS; its tables stay.");
4596
+ if (key === "captcha") {
4597
+ text = setEnvValues(text, { CAPTCHA: to.captcha });
4598
+ changes.push(to.captcha === "on" ? "The captcha of the contact form is on." : "The captcha is off: public forms need no captcha token.");
4599
+ }
4583
4600
  if (key === "media") changes.push(to.media === "s3" ? "Uploads in production go to S3; dstack settings asks for the S3 settings." : "Uploads in production stay on the server.");
4584
4601
  }
4585
4602
  return {
@@ -4998,6 +5015,11 @@ async function update(options) {
4998
5015
  }).status !== 0) fail("uv could not update backend/uv.lock.");
4999
5016
  p.log.success(options.all ? "Updated backend/uv.lock" : "Updated django-stack in backend/uv.lock");
5000
5017
  } else if (existsSync(lock)) p.log.warn("uv is not installed, so backend/uv.lock stays as it is: https://docs.astral.sh/uv/");
5018
+ if (frontendPackageManager(project.directory)) {
5019
+ const lockfile = updateFrontendLock(project.directory, options.all);
5020
+ if (!lockfile) fail("The package manager could not update the frontend's lockfile.");
5021
+ p.log.success(options.all ? `Updated frontend/${lockfile}` : `Updated the @django-stack packages in frontend/${lockfile}`);
5022
+ }
5001
5023
  for (const target of targets) {
5002
5024
  if (compose(target, ["pull", "--ignore-buildable"]) !== 0) fail("Pulling the images failed.");
5003
5025
  const build = [
@@ -5058,6 +5080,9 @@ async function config(options) {
5058
5080
  if (!existsSync(compose) || !/^ {2}umami:/m.test(readFileSync(compose, "utf8"))) fail("compose.yaml has no umami service. Add it as the analytics docs describe, then run this again.");
5059
5081
  }
5060
5082
  const settingsPath = join(project.directory, "backend", "config", "settings.py");
5083
+ if (next.captcha === "on" && current.captcha !== "on") {
5084
+ if (!existsSync(settingsPath) || !CAPTCHA_APP.test(readFileSync(settingsPath, "utf8"))) fail("The backend has no django_stack.contrib.captcha. Add it and the widget as the captcha docs describe, then run this again.");
5085
+ }
5061
5086
  let settingsText;
5062
5087
  if (next.notifications !== current.notifications) {
5063
5088
  if (!existsSync(settingsPath)) fail("This project has no backend/config/settings.py.");
@@ -5301,7 +5326,7 @@ function createProgram() {
5301
5326
  program.command("logs").description("Show the logs of the services").argument("[services...]", "Only these services, such as backend").addOption(dirOption()).option("--no-follow", "Print the logs and exit").option("-n, --tail <lines>", "Only the last lines of each service").action(logs);
5302
5327
  program.command("status").description("Show the containers and whether the database, cache, worker and backups are healthy").addOption(dirOption()).action(status);
5303
5328
  program.command("check").description("Check the project: setup, Django checks, migrations and linters").addOption(dirOption()).option("--tests", "Run the backend and frontend tests too").action(check);
5304
- program.command("update").description("Pull and rebuild the images and update the DjangoStack packages").addOption(dirOption()).option("--all", "Update every backend package in uv.lock, not only django-stack").option("--no-cache", "Rebuild the images from scratch").option("--no-restart", "Don't restart what was running").action(update);
5329
+ program.command("update").description("Pull and rebuild the images and update the DjangoStack packages").addOption(dirOption()).option("--all", "Update every package in the lockfiles, not only DjangoStack's").option("--no-cache", "Rebuild the images from scratch").option("--no-restart", "Don't restart what was running").action(update);
5305
5330
  const configCommand = program.command("config").description("Change the stack options of a project").addOption(dirOption()).option("-y, --yes", "Don't ask; change only what the flags set").action((options, cmd) => config({
5306
5331
  ...options,
5307
5332
  fromCli: (option) => cmd.getOptionValueSource(option) === "cli"
package/dist/index.d.ts CHANGED
@@ -174,7 +174,7 @@ export declare const STACK_OPTIONS: readonly [{
174
174
  readonly value: "on";
175
175
  readonly label: "On";
176
176
  readonly hint: "proof of work, no tracking";
177
- readonly generated: false;
177
+ readonly generated: true;
178
178
  }, {
179
179
  readonly value: "off";
180
180
  readonly label: "Off";
@@ -206,7 +206,8 @@ export declare const STACK_OPTIONS: readonly [{
206
206
  readonly values: readonly [{
207
207
  readonly value: "pnpm";
208
208
  readonly label: "pnpm";
209
- readonly generated: false;
209
+ readonly hint: "with a day's delay on new releases";
210
+ readonly generated: true;
210
211
  }, {
211
212
  readonly value: "npm";
212
213
  readonly label: "npm";
@@ -214,7 +215,8 @@ export declare const STACK_OPTIONS: readonly [{
214
215
  }, {
215
216
  readonly value: "bun";
216
217
  readonly label: "Bun";
217
- readonly generated: false;
218
+ readonly hint: "for installs; the frontend runs on Node.js";
219
+ readonly generated: true;
218
220
  }];
219
221
  }, {
220
222
  readonly key: "media";
@@ -247,8 +249,8 @@ export declare const STACK_OPTIONS: readonly [{
247
249
  }, {
248
250
  readonly value: "sentry";
249
251
  readonly label: "Sentry";
250
- readonly hint: "or GlitchTip, with SENTRY_DSN";
251
- readonly generated: false;
252
+ readonly hint: "sentry.io, with SENTRY_DSN";
253
+ readonly generated: true;
252
254
  }, {
253
255
  readonly value: "off";
254
256
  readonly label: "Off";
@@ -296,6 +298,10 @@ interface ScaffoldOptions {
296
298
  analytics?: boolean;
297
299
  /** Content Studio's notifications app; on by default. */
298
300
  notifications?: boolean;
301
+ /** The captcha of the contact form; on by default. */
302
+ captcha?: boolean;
303
+ /** The frontend's package manager; pnpm by default. */
304
+ packageManager?: PackageManager;
299
305
  /** How errors are reported in production; by email by default. */
300
306
  errors?: Stack["errors"];
301
307
  /** The cache backend; the database by default. */
@@ -313,6 +319,8 @@ interface ScaffoldOptions {
313
319
  /** Where the templates are; the ones in the package by default. */
314
320
  templatesDir?: string;
315
321
  }
322
+ /** A package manager of the frontend. */
323
+ type PackageManager = Stack["packageManager"];
316
324
  interface ScaffoldResult {
317
325
  directory: string;
318
326
  adminUsername: string;
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export { F as DEFAULT_STACK, i as FRONTENDS, I as NAMED_PRESETS, R as STACK_OPTIONS, y as VERSION, o as cacheEnv, B as decodePreset, V as encodePreset, x as generateProjectSecrets, S as generateSecret, l as normalizeDatabaseUrl, tt as parseEnv, H as pendingChoices, n as productionEnv, U as resolvePreset, X as runDoctor, d as scaffold, rt as setEnvValues, f as setNotificationsApp, m as toProjectName, g as usesValkey, r as validateDomain, _ as validateProjectName } from "./production-M2SsuuBb.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-Ct_qoeXk.js";
@@ -599,7 +599,7 @@ const STACK_OPTIONS = [
599
599
  value: "on",
600
600
  label: "On",
601
601
  hint: "proof of work, no tracking",
602
- generated: false
602
+ generated: true
603
603
  }, {
604
604
  value: "off",
605
605
  label: "Off",
@@ -634,7 +634,8 @@ const STACK_OPTIONS = [
634
634
  {
635
635
  value: "pnpm",
636
636
  label: "pnpm",
637
- generated: false
637
+ hint: "with a day's delay on new releases",
638
+ generated: true
638
639
  },
639
640
  {
640
641
  value: "npm",
@@ -644,7 +645,8 @@ const STACK_OPTIONS = [
644
645
  {
645
646
  value: "bun",
646
647
  label: "Bun",
647
- generated: false
648
+ hint: "for installs; the frontend runs on Node.js",
649
+ generated: true
648
650
  }
649
651
  ]
650
652
  },
@@ -682,8 +684,8 @@ const STACK_OPTIONS = [
682
684
  {
683
685
  value: "sentry",
684
686
  label: "Sentry",
685
- hint: "or GlitchTip, with SENTRY_DSN",
686
- generated: false
687
+ hint: "sentry.io, with SENTRY_DSN",
688
+ generated: true
687
689
  },
688
690
  {
689
691
  value: "off",
@@ -973,7 +975,8 @@ function projectStack(project) {
973
975
  const fromEnv = {
974
976
  analytics: project.profiles.includes("analytics") ? "umami" : "off",
975
977
  ...project.env.CACHE_URL === void 0 ? {} : cacheStack(project.env),
976
- ...notificationsStack(project)
978
+ ...notificationsStack(project),
979
+ ...captchaStack(project)
977
980
  };
978
981
  const { preset } = projectManifest(project);
979
982
  if (preset) try {
@@ -994,6 +997,17 @@ function notificationsStack(project) {
994
997
  if (!existsSync(settings)) return {};
995
998
  return { notifications: /^ *["']content_studio\.notifications["'],/m.test(readFileSync(settings, "utf8")) ? "on" : "off" };
996
999
  }
1000
+ /** The line of DjangoStack's captcha app in the backend's INSTALLED_APPS. */
1001
+ const CAPTCHA_APP = /^ *["']django_stack\.contrib\.captcha["'],/m;
1002
+ /**
1003
+ * The captcha is on when the backend has DjangoStack's captcha app and
1004
+ * CAPTCHA is not off. Projects from before the captcha have no app.
1005
+ */
1006
+ function captchaStack(project) {
1007
+ const settings = join(project.directory, "backend", "config", "settings.py");
1008
+ if (!existsSync(settings)) return {};
1009
+ return { captcha: CAPTCHA_APP.test(readFileSync(settings, "utf8")) && project.env.CAPTCHA !== "off" ? "on" : "off" };
1010
+ }
997
1011
  const REDIS = /^(redis|rediss|valkey|valkeys):/;
998
1012
  /**
999
1013
  * The cache and the tasks a project's `.env` runs. Projects from before 0.7
@@ -1147,6 +1161,56 @@ const PACKAGE_NAME = "@django-stack/cli";
1147
1161
  // src/scaffold.ts
1148
1162
  /** The frontends this version of the CLI generates. */
1149
1163
  const FRONTENDS = stackOption("frontend").values.filter((value) => value.generated).map((value) => value.value);
1164
+ /** The pnpm and Bun versions the frontend image installs with. */
1165
+ const PNPM_VERSION = "12.18.0";
1166
+ const BUN_VERSION = "1.4.4";
1167
+ const LOCKFILES = {
1168
+ pnpm: "pnpm-lock.yaml",
1169
+ npm: "package-lock.json",
1170
+ bun: "bun.lock"
1171
+ };
1172
+ /** The install command of a package manager: frozen to the lockfile when there is one. */
1173
+ function installCommand(manager, flags = "") {
1174
+ const frozen = manager === "npm" ? "npm ci" : `${manager} install --frozen-lockfile`;
1175
+ return `if [ -f ${LOCKFILES[manager]} ]; then ${frozen}${flags}; else ${manager} install${flags}; fi`;
1176
+ }
1177
+ /**
1178
+ * The template values for a package manager: the commands in the README and
1179
+ * AGENTS.md, and the install steps of the frontend's Dockerfile. Scripts run
1180
+ * with npm in the image whatever installed them; it comes with Node.js.
1181
+ */
1182
+ function packageManagerValues(manager) {
1183
+ return {
1184
+ package_manager: manager,
1185
+ pm_run: {
1186
+ pnpm: "pnpm",
1187
+ npm: "npm run",
1188
+ bun: "bun run"
1189
+ }[manager],
1190
+ pm_backend: {
1191
+ pnpm: "pnpm backend",
1192
+ npm: "npm run backend --",
1193
+ bun: "bun run backend"
1194
+ }[manager],
1195
+ pm_setup: {
1196
+ pnpm: `RUN npm install --global pnpm@${PNPM_VERSION}`,
1197
+ npm: "# npm comes with Node.js.",
1198
+ bun: `COPY --from=oven/bun:${BUN_VERSION}-alpine /usr/local/bin/bun /usr/local/bin/bun`
1199
+ }[manager],
1200
+ pm_manifests: {
1201
+ pnpm: "package.json pnpm-lock.yaml* pnpm-workspace.yaml",
1202
+ npm: "package.json package-lock.json*",
1203
+ bun: "package.json bun.lock*"
1204
+ }[manager],
1205
+ pm_install: installCommand(manager),
1206
+ pm_install_no_scripts: installCommand(manager, " --ignore-scripts"),
1207
+ pm_prune: {
1208
+ pnpm: "pnpm prune --prod",
1209
+ npm: "npm prune --omit=dev",
1210
+ bun: "rm -rf node_modules && bun install --production"
1211
+ }[manager]
1212
+ };
1213
+ }
1150
1214
  /** Umami's database URL: the project's database, with Umami's tables in the `umami` schema. */
1151
1215
  function umamiDatabaseUrl(databaseUrl) {
1152
1216
  const url = new URL(databaseUrl);
@@ -1291,6 +1355,7 @@ function scaffold(options) {
1291
1355
  const about = join(target, "frontend", "template.json");
1292
1356
  const frontend = JSON.parse(readFileSync(about, "utf8"));
1293
1357
  rmSync(about);
1358
+ const packageManager = options.packageManager ?? "pnpm";
1294
1359
  const values = {
1295
1360
  project_name: options.name,
1296
1361
  frontend: options.frontend,
@@ -1300,7 +1365,8 @@ function scaffold(options) {
1300
1365
  cli_version: CLI_VERSION,
1301
1366
  preset: options.preset ?? encodePreset(DEFAULT_STACK),
1302
1367
  site_port: String(options.ports?.SITE_PORT ?? 3e3),
1303
- mail_port: String(options.ports?.MAIL_PORT ?? 8025)
1368
+ mail_port: String(options.ports?.MAIL_PORT ?? 8025),
1369
+ ...packageManagerValues(packageManager)
1304
1370
  };
1305
1371
  const files = walk(target);
1306
1372
  for (const file of files) {
@@ -1309,6 +1375,16 @@ function scaffold(options) {
1309
1375
  const rendered = render(text, values);
1310
1376
  if (rendered !== text) writeFileSync(file, rendered);
1311
1377
  }
1378
+ if (packageManager !== "pnpm") rmSync(join(target, "frontend", "pnpm-workspace.yaml"), { force: true });
1379
+ if (packageManager !== "npm") {
1380
+ const packageJson = join(target, "frontend", "package.json");
1381
+ const manifest = JSON.parse(readFileSync(packageJson, "utf8"));
1382
+ const version = packageManager === "pnpm" ? PNPM_VERSION : BUN_VERSION;
1383
+ writeFileSync(packageJson, `${JSON.stringify({
1384
+ ...manifest,
1385
+ packageManager: `${packageManager}@${version}`
1386
+ }, null, 2)}\n`);
1387
+ }
1312
1388
  if (options.notifications === false) {
1313
1389
  const settings = join(target, "backend", "config", "settings.py");
1314
1390
  writeFileSync(settings, setNotificationsApp(readFileSync(settings, "utf8"), false));
@@ -1342,7 +1418,8 @@ function scaffold(options) {
1342
1418
  env = setEnvValues(env, { COMPOSE_PROFILES: profiles.join(",") });
1343
1419
  if (options.ports) env = setEnvValues(env, portValues(parseEnv(env), options.ports));
1344
1420
  if (!options.demoContent) env = setEnvValues(env, { SEED_DEMO_CONTENT: "0" });
1345
- if (options.errors === "off") env = setEnvValues(env, { ERRORS: "off" });
1421
+ if (options.errors && options.errors !== "email") env = setEnvValues(env, { ERRORS: options.errors });
1422
+ if (options.captcha === false) env = setEnvValues(env, { CAPTCHA: "off" });
1346
1423
  writeFileSync(join(target, ".env"), env, { mode: 384 });
1347
1424
  writeFileSync(join(target, ".env.example"), envExample);
1348
1425
  rmSync(join(target, "env.example"));
@@ -1407,6 +1484,74 @@ function linkPackages(target, monorepo) {
1407
1484
  writeFileSync(packageJson, `${JSON.stringify(manifest, null, 2)}\n`);
1408
1485
  }
1409
1486
  /** Locks the backend's dependencies with uv, when uv is installed. */
1487
+ /** Runs a package manager in the frontend directory: pnpm and Bun through npx, at the image's version. */
1488
+ function runPackageManager(directory, manager, args) {
1489
+ const [file, commandArgs] = manager === "npm" ? ["npm", args] : ["npx", [
1490
+ "--yes",
1491
+ `${manager}@${manager === "pnpm" ? PNPM_VERSION : BUN_VERSION}`,
1492
+ ...args
1493
+ ]];
1494
+ try {
1495
+ execFileSync(file, commandArgs, {
1496
+ cwd: directory,
1497
+ stdio: "ignore",
1498
+ shell: process.platform === "win32"
1499
+ });
1500
+ return true;
1501
+ } catch {
1502
+ return false;
1503
+ }
1504
+ }
1505
+ /** Writes the frontend's lockfile with the chosen package manager, without installing anything. */
1506
+ function lockFrontend(target, manager) {
1507
+ const directory = join(target, "frontend");
1508
+ return runPackageManager(directory, manager, manager === "npm" ? [
1509
+ "install",
1510
+ "--package-lock-only",
1511
+ "--ignore-scripts",
1512
+ "--no-audit",
1513
+ "--no-fund"
1514
+ ] : ["install", "--lockfile-only"]) && existsSync(join(directory, LOCKFILES[manager]));
1515
+ }
1516
+ /** The package manager of a project's frontend, from its lockfile. */
1517
+ function frontendPackageManager(directory) {
1518
+ return Object.keys(LOCKFILES).find((manager) => existsSync(join(directory, "frontend", LOCKFILES[manager])));
1519
+ }
1520
+ /**
1521
+ * Updates the frontend's lockfile, without installing: the @django-stack
1522
+ * packages, or every package with `all`, within the ranges of package.json.
1523
+ * Returns the lockfile, or undefined without one or on failure.
1524
+ */
1525
+ function updateFrontendLock(target, all = false) {
1526
+ const manager = frontendPackageManager(target);
1527
+ if (!manager) return void 0;
1528
+ const directory = join(target, "frontend");
1529
+ const manifest = JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
1530
+ const names = Object.keys(manifest.dependencies ?? {}).filter((name) => name.startsWith("@django-stack/"));
1531
+ const packages = all ? [] : names;
1532
+ const args = {
1533
+ pnpm: [
1534
+ "update",
1535
+ ...packages,
1536
+ "--lockfile-only"
1537
+ ],
1538
+ npm: [
1539
+ "update",
1540
+ ...packages,
1541
+ "--package-lock-only",
1542
+ "--ignore-scripts",
1543
+ "--no-audit",
1544
+ "--no-fund"
1545
+ ],
1546
+ bun: [
1547
+ "update",
1548
+ ...packages,
1549
+ "--lockfile-only"
1550
+ ]
1551
+ }[manager];
1552
+ if (!all && !names.length) return LOCKFILES[manager];
1553
+ return runPackageManager(directory, manager, args) ? LOCKFILES[manager] : void 0;
1554
+ }
1410
1555
  function lockBackend(target) {
1411
1556
  try {
1412
1557
  execFileSync("uv", ["lock", "--quiet"], {
@@ -1499,4 +1644,4 @@ function productionEnv(devEnv, options) {
1499
1644
  todo
1500
1645
  };
1501
1646
  }
1502
- export { dockerRunning as $, projectStack as A, decodePreset as B, RUN_DOMAIN as C, composeOutput as D, compose as E, DEFAULT_STACK as F, DEV_PORTS as G, pendingChoices as H, NAMED_PRESETS as I, envPorts as J, chooseFreePorts as K, PROMPT_ORDER as L, readLocalRun as M, readProject as N, findProjectDirectory as O, runningServices as P, dockerNotRunning as Q, STACK_OPTIONS as R, generateSecret as S, activeProject as T, resolvePreset as U, encodePreset as V, stackOption as W, runDoctor as X, portValues as Y, checkTools as Z, validateProjectName as _, LOCAL_S3 as a, PACKAGE_NAME as b, lockBackend as c, scaffold as d, isDockerDown as et, setNotificationsApp as f, usesValkey as g, umamiEnv as h, FRONTENDS as i, projectUrls as j, parseProfiles as k, normalizeDatabaseUrl as l, toProjectName as m, productionEnv as n, quoteEnvValue as nt, cacheEnv as o, timeZones as p, describeMove as q, validateDomain as r, setEnvValues as rt, localS3 as s, missingS3 as t, parseEnv as tt, relativeDirectory as u, validateTimeZone as v, RUN_ENV as w, generateProjectSecrets as x, CLI_VERSION as y, applies as z };
1647
+ export { envPorts as $, compose as A, NAMED_PRESETS as B, PACKAGE_NAME as C, RUN_DOMAIN as D, CAPTCHA_APP as E, projectUrls as F, decodePreset as G, PROMPT_ORDER as H, readLocalRun as I, resolvePreset as J, encodePreset as K, readProject as L, findProjectDirectory as M, parseProfiles as N, RUN_ENV as O, projectStack as P, describeMove as Q, runningServices as R, CLI_VERSION as S, generateSecret as T, STACK_OPTIONS as U, NODE_FRONTENDS as V, applies as W, DEV_PORTS as X, stackOption as Y, chooseFreePorts as Z, umamiEnv as _, LOCAL_S3 as a, isDockerDown as at, validateProjectName as b, localS3 as c, setEnvValues as ct, normalizeDatabaseUrl as d, portValues as et, relativeDirectory as f, toProjectName as g, timeZones as h, FRONTENDS as i, dockerRunning as it, composeOutput as j, activeProject as k, lockBackend as l, setNotificationsApp as m, productionEnv as n, checkTools as nt, cacheEnv as o, parseEnv as ot, scaffold as p, pendingChoices as q, validateDomain as r, dockerNotRunning as rt, frontendPackageManager as s, quoteEnvValue as st, missingS3 as t, runDoctor as tt, lockFrontend as u, updateFrontendLock as v, generateProjectSecrets as w, validateTimeZone as x, usesValkey as y, DEFAULT_STACK as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@django-stack/cli",
3
- "version": "0.9.0",
3
+ "version": "0.10.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",
@@ -40,6 +40,7 @@ INSTALLED_APPS = [
40
40
  "content_studio.contrib.password_reset",
41
41
  "content_studio.notifications", # notifications in the admin and by email
42
42
  "django_stack",
43
+ "django_stack.contrib.captcha", # the captcha of the contact form (CAPTCHA=off turns it off)
43
44
  "content",
44
45
  # Last: builds the API routes once every model is exposed.
45
46
  "headless.rest",
@@ -5,8 +5,8 @@ site settings, menus, redirects and form submissions.
5
5
  These models are yours: add fields, remove models, create new ones. Every
6
6
  model exposed with ``@expose()`` gets routes under /api/ (see
7
7
  https://djangoheadless.org/docs/models); register it in ``admin.py`` to edit
8
- it in Content Studio. After a change, run ``pnpm backend makemigrations``
9
- and ``pnpm generate:types``.
8
+ it in Content Studio. After a change, run ``{{pm_run}} backend makemigrations``
9
+ and ``{{pm_run}} generate:types``.
10
10
  """
11
11
 
12
12
  from content_studio.blocks import Block, BlockField
@@ -1,12 +1,13 @@
1
1
  """
2
2
  Smoke tests of the content API: every exposed model answers the read-only
3
3
  key, drafts stay hidden, and the contact form takes submissions.
4
- Run them with `pnpm test` at the project root.
4
+ Run them with `{{pm_run}} test` at the project root.
5
5
  """
6
6
 
7
7
  import pytest
8
8
  from django.apps import apps
9
9
  from django.conf import settings
10
+ from django_stack.contrib.captcha.testing import captcha_token
10
11
  from rest_framework.test import APIClient
11
12
 
12
13
  from content.models import FormSubmission, Page, Post
@@ -63,13 +64,24 @@ def test_post_by_slug(reader, seeded):
63
64
  def test_contact_form(seeded):
64
65
  response = APIClient().post(
65
66
  "/api/content.formsubmission",
66
- {"name": "Ada", "email": "ada@example.com", "message": "Hello"},
67
+ {"name": "Ada", "email": "ada@example.com", "message": "Hello", "captcha": captcha_token()},
67
68
  format="json",
68
69
  )
69
70
  assert response.status_code == 201
70
71
  assert FormSubmission.objects.filter(email="ada@example.com").exists()
71
72
 
72
73
 
74
+ @pytest.mark.skipif(not settings.DJANGO_STACK["CAPTCHA"], reason="the captcha is off")
75
+ def test_contact_form_needs_a_captcha(seeded):
76
+ response = APIClient().post(
77
+ "/api/content.formsubmission",
78
+ {"name": "Ada", "email": "ada@example.com", "message": "Hello"},
79
+ format="json",
80
+ )
81
+ assert response.status_code == 400
82
+ assert "captcha" in response.json()
83
+
84
+
73
85
  @pytest.mark.skipif(not apps.is_installed("content_studio.notifications"), reason="notifications are off")
74
86
  def test_contact_form_notifies_the_editors(seeded, django_capture_on_commit_callbacks):
75
87
  from content_studio.notifications.models import Notification
@@ -79,7 +91,7 @@ def test_contact_form_notifies_the_editors(seeded, django_capture_on_commit_call
79
91
  with django_capture_on_commit_callbacks(execute=True):
80
92
  APIClient().post(
81
93
  "/api/content.formsubmission",
82
- {"name": "Ada", "email": "ada@example.com", "message": "100% sure"},
94
+ {"name": "Ada", "email": "ada@example.com", "message": "100% sure", "captcha": captcha_token()},
83
95
  format="json",
84
96
  )
85
97
  notification = Notification.objects.get(recipient=editor, event="site.form_submitted")
@@ -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.9.0",
8
+ "django-stack[server,s3]>=0.10.0",
9
9
  "django-headless[schema]>=1.1.1",
10
10
  "django-content-studio[images,huey]>=1.11.0",
11
11
  ]
@@ -17,7 +17,7 @@ backend/ Django project
17
17
  content/seed.py demo content
18
18
  content/tests/ pytest tests of the API
19
19
  frontend/ the {{frontend_label}} site; frontend/AGENTS.md lists its files
20
- src/lib/headless.ts generated API types; do not edit, run `npm run generate:types`
20
+ src/lib/headless.ts generated API types; do not edit, run `{{pm_run}} generate:types`
21
21
  compose.yaml every service; COMPOSE_PROFILES in .env picks dev or prod
22
22
  Caddyfile the prod profile's reverse proxy and HTTPS
23
23
  .github/workflows/deploy.yml optional deploy to a server over SSH
@@ -26,16 +26,16 @@ Caddyfile the prod profile's reverse proxy and HTTPS
26
26
 
27
27
  ## Commands
28
28
 
29
- Run them at the project root. They need Docker; Node is only needed for the `npm run` shortcuts.
29
+ Run them at the project root. They need Docker; Node is only needed for the `{{pm_run}}` shortcuts.
30
30
 
31
31
  | Task | Command |
32
32
  |------|---------|
33
- | Start everything | `docker compose up --build` (or `npm run dev`) |
34
- | Run a manage.py command | `docker compose exec backend python manage.py <command>` (or `npm run backend -- <command>`) |
33
+ | Start everything | `docker compose up --build` (or `{{pm_run}} dev`) |
34
+ | Run a manage.py command | `docker compose exec backend python manage.py <command>` (or `{{pm_backend}} <command>`) |
35
35
  | Make migrations after a model change | `docker compose exec backend python manage.py makemigrations` |
36
- | Regenerate the API types after a model change | `npm run generate:types` |
37
- | Run the tests | `npm test` |
38
- | Lint and type-check | `npm run lint` |
36
+ | Regenerate the API types after a model change | `{{pm_run}} generate:types` |
37
+ | Run the tests | `{{pm_run}} test` |
38
+ | Lint and type-check | `{{pm_run}} lint` |
39
39
  | Logs | `docker compose logs --follow backend` |
40
40
  | Production settings for a server | `npx @django-stack/cli production --domain example.com` |
41
41
 
@@ -54,7 +54,7 @@ in a running container.
54
54
  `ModelStudio`.
55
55
  3. Run `makemigrations`; the backend migrates on its next start, or run
56
56
  `migrate`.
57
- 4. Run `npm run generate:types`, then read it in the frontend as
57
+ 4. Run `{{pm_run}} generate:types`, then read it in the frontend as
58
58
  `frontend/AGENTS.md` shows.
59
59
  5. Add an API smoke test to `backend/content/tests/test_api.py`.
60
60
 
@@ -38,9 +38,9 @@ the frontend {{frontend_label}}'s, with the source mounted from `backend/` and
38
38
  |------|---------|
39
39
  | Run a manage.py command | `docker compose exec backend python manage.py <command>` |
40
40
  | Make migrations | `docker compose exec backend python manage.py makemigrations` |
41
- | Regenerate the API types | `npm run generate:types` |
42
- | Run the tests | `npm test` |
43
- | Check the setup | `npm run doctor` |
41
+ | Regenerate the API types | `{{pm_run}} generate:types` |
42
+ | Run the tests | `{{pm_run}} test` |
43
+ | Check the setup | `{{pm_run}} doctor` |
44
44
 
45
45
  With the `dstack` command installed (`npm i -g @django-stack/cli`),
46
46
  `dstack dev`, `dstack check`, `dstack manage <command>` and `dstack types` do
@@ -45,6 +45,13 @@ x-frontend-env: &frontend-env
45
45
  # Page analytics: the tracker loads from /_a/ on the site when set.
46
46
  UMAMI_WEBSITE_ID: ${UMAMI_WEBSITE_ID:-}
47
47
  UMAMI_URL: ${UMAMI_URL:-}
48
+ # The captcha widget of the contact form, unless CAPTCHA=off.
49
+ CAPTCHA: ${CAPTCHA:-on}
50
+ # Server errors to Sentry with ERRORS=sentry, as the backend's.
51
+ ERRORS: ${ERRORS:-email}
52
+ SENTRY_DSN: ${SENTRY_DSN:-}
53
+ SENTRY_ENVIRONMENT: ${SENTRY_ENVIRONMENT:-production}
54
+ SENTRY_RELEASE: ${SENTRY_RELEASE:-}
48
55
 
49
56
  x-backend-depends: &backend-depends
50
57
  db:
@@ -118,12 +118,24 @@ BACKUP_S3_BUCKET=
118
118
  # an Uptime Kuma push monitor, which alerts you when the pings stop.
119
119
  BACKUP_PING_URL=
120
120
 
121
+ # The captcha of the contact form: a proof of work the visitor's browser
122
+ # solves, served by Django at /api/cap/, with no third party. CAPTCHA=off
123
+ # turns it off for the backend and the frontend.
124
+ CAPTCHA=on
125
+
121
126
  # Error reports (with DJANGO_DEBUG=0): each new error of the backend, the
122
127
  # worker or a backup is mailed to ERROR_EMAIL (comma separated) or, when it
123
128
  # is empty, to the superusers, and shows in the admin's notifications. The
124
129
  # same error is mailed once an hour at most. ERRORS=off turns it off.
125
130
  ERRORS=email
126
131
  ERROR_EMAIL=
132
+ # ERRORS=sentry sends the errors of the backend, the worker and the
133
+ # frontend's server to Sentry instead: create a Django project on sentry.io
134
+ # and paste its DSN here. SENTRY_TRACES_SAMPLE_RATE (0 to 1) also sends that
135
+ # share of the backend's requests as performance traces.
136
+ SENTRY_DSN=
137
+ SENTRY_ENVIRONMENT=production
138
+ SENTRY_TRACES_SAMPLE_RATE=0
127
139
 
128
140
  # Logs: one JSON object per line in production, plain text in development
129
141
  # (LOG_FORMAT=json or plain). LOG_ACCESS=0 turns off Django's access log.
@@ -12,7 +12,7 @@ src/layouts/Base.astro the page shell: head, menus, footer, preview banner
12
12
  src/components/ Blocks.astro maps block types to components in components/blocks/
13
13
  src/lib/api.ts getContent(): content helpers with the read-only key, drafts in preview
14
14
  src/lib/cache.ts the in-memory page cache that /revalidate empties
15
- src/lib/headless.ts generated API types; do not edit, run `npm run generate:types`
15
+ src/lib/headless.ts generated API types; do not edit, run `{{pm_run}} generate:types`
16
16
  src/middleware.ts page cache, editor-managed redirects, security headers
17
17
  astro.config.mjs server output, Content-Security-Policy, the dev proxy to Django
18
18
  ```
@@ -39,5 +39,5 @@ Run them in the `frontend` container (`docker compose exec frontend <command>`):
39
39
 
40
40
  | Task | Command |
41
41
  |------|---------|
42
- | Type-check | `npm run check` |
43
- | Test | `npm test` |
42
+ | Type-check | `{{pm_run}} check` |
43
+ | Test | `{{pm_run}} test` |
@@ -7,10 +7,13 @@ WORKDIR /app
7
7
  ENV ASTRO_TELEMETRY_DISABLED=1
8
8
 
9
9
  FROM base AS deps
10
- COPY package.json package-lock.json* ./
10
+ {{pm_setup}}
11
+ COPY {{pm_manifests}} ./
11
12
  COPY vendor/ vendor/
12
13
  # No BuildKit cache mounts: Railway rejects them, and the layer is cached anyway.
13
- RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
14
+ RUN {{pm_install}}
15
+ # The package manager only installs: npm, which comes with Node.js, runs
16
+ # the scripts of package.json below.
14
17
 
15
18
  FROM deps AS dev
16
19
  EXPOSE 3000
@@ -21,7 +24,7 @@ CMD ["sh", "-c", "rm -f .astro/dev.json && exec npm run dev"]
21
24
 
22
25
  FROM deps AS build
23
26
  COPY . .
24
- RUN npm run build && npm prune --omit=dev
27
+ RUN npm run build && {{pm_prune}}
25
28
 
26
29
  FROM base AS prod
27
30
  ENV NODE_ENV=production HOST=0.0.0.0 PORT=3000