@django-stack/cli 0.8.1 → 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 (50) hide show
  1. package/dist/bin.js +840 -54
  2. package/dist/index.d.ts +43 -4
  3. package/dist/index.js +1 -1
  4. package/dist/{production-CGkI7oNe.js → production-Ct_qoeXk.js} +210 -7
  5. package/package.json +1 -1
  6. package/templates/backend/Dockerfile +4 -5
  7. package/templates/backend/config/settings.py +3 -0
  8. package/templates/backend/config/studio.py +4 -0
  9. package/templates/backend/content/apps.py +5 -1
  10. package/templates/backend/content/models.py +2 -2
  11. package/templates/backend/content/notifications.py +36 -0
  12. package/templates/backend/content/tests/test_api.py +32 -2
  13. package/templates/backend/pyproject.toml +1 -1
  14. package/templates/base/AGENTS.md +8 -8
  15. package/templates/base/Caddyfile +7 -1
  16. package/templates/base/README.md +3 -3
  17. package/templates/base/compose.yaml +37 -2
  18. package/templates/base/env.example +29 -0
  19. package/templates/frontend-astro/AGENTS.md +3 -3
  20. package/templates/frontend-astro/Dockerfile +7 -4
  21. package/templates/frontend-astro/astro.config.mjs +16 -5
  22. package/templates/frontend-astro/package.json +1 -1
  23. package/templates/frontend-astro/pnpm-workspace.yaml +17 -0
  24. package/templates/frontend-astro/src/components/ContactForm.astro +19 -3
  25. package/templates/frontend-astro/src/middleware.ts +9 -1
  26. package/templates/frontend-next/AGENTS.md +3 -3
  27. package/templates/frontend-next/Dockerfile +6 -3
  28. package/templates/frontend-next/package.json +3 -3
  29. package/templates/frontend-next/pnpm-workspace.yaml +11 -0
  30. package/templates/frontend-next/src/app/[...path]/page.tsx +2 -1
  31. package/templates/frontend-next/src/components/ContactForm.tsx +14 -3
  32. package/templates/frontend-next/src/instrumentation.ts +12 -0
  33. package/templates/frontend-next/src/proxy.ts +4 -1
  34. package/templates/frontend-nuxt/AGENTS.md +3 -3
  35. package/templates/frontend-nuxt/Dockerfile +6 -3
  36. package/templates/frontend-nuxt/nuxt.config.ts +7 -3
  37. package/templates/frontend-nuxt/package.json +2 -2
  38. package/templates/frontend-nuxt/pnpm-workspace.yaml +13 -0
  39. package/templates/frontend-nuxt/server/plugins/errors.ts +14 -0
  40. package/templates/frontend-nuxt/server/routes/_content/layout.get.ts +4 -0
  41. package/templates/frontend-nuxt/src/components/ContactForm.vue +15 -2
  42. package/templates/frontend-sveltekit/AGENTS.md +3 -3
  43. package/templates/frontend-sveltekit/Dockerfile +7 -4
  44. package/templates/frontend-sveltekit/package.json +2 -2
  45. package/templates/frontend-sveltekit/pnpm-workspace.yaml +11 -0
  46. package/templates/frontend-sveltekit/src/hooks.server.ts +13 -1
  47. package/templates/frontend-sveltekit/src/lib/components/ContactForm.svelte +15 -2
  48. package/templates/frontend-sveltekit/src/routes/+layout.server.ts +3 -0
  49. package/templates/frontend-sveltekit/src/routes/[...path]/+page.svelte +1 -1
  50. package/templates/frontend-sveltekit/vite.config.ts +10 -3
package/dist/index.d.ts CHANGED
@@ -158,7 +158,7 @@ export declare const STACK_OPTIONS: readonly [{
158
158
  readonly value: "on";
159
159
  readonly label: "On";
160
160
  readonly hint: "in the admin and by email";
161
- readonly generated: false;
161
+ readonly generated: true;
162
162
  }, {
163
163
  readonly value: "off";
164
164
  readonly label: "Off";
@@ -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";
@@ -233,6 +235,28 @@ export declare const STACK_OPTIONS: readonly [{
233
235
  readonly hint: "any S3-compatible service; credentials later";
234
236
  readonly generated: true;
235
237
  }];
238
+ }, {
239
+ readonly key: "errors";
240
+ readonly flag: "--errors";
241
+ readonly message: "Error reports";
242
+ readonly description: "How you hear about errors in production";
243
+ readonly bits: 2;
244
+ readonly values: readonly [{
245
+ readonly value: "email";
246
+ readonly label: "Email";
247
+ readonly hint: "to the superusers, and in the admin";
248
+ readonly generated: true;
249
+ }, {
250
+ readonly value: "sentry";
251
+ readonly label: "Sentry";
252
+ readonly hint: "sentry.io, with SENTRY_DSN";
253
+ readonly generated: true;
254
+ }, {
255
+ readonly value: "off";
256
+ readonly label: "Off";
257
+ readonly hint: "the logs only";
258
+ readonly generated: true;
259
+ }];
236
260
  }];
237
261
  type Options = (typeof STACK_OPTIONS)[number];
238
262
  type StackKey = Options["key"];
@@ -272,6 +296,14 @@ interface ScaffoldOptions {
272
296
  localS3?: boolean;
273
297
  /** Page analytics with the bundled Umami (the `analytics` profile). */
274
298
  analytics?: boolean;
299
+ /** Content Studio's notifications app; on by default. */
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;
305
+ /** How errors are reported in production; by email by default. */
306
+ errors?: Stack["errors"];
275
307
  /** The cache backend; the database by default. */
276
308
  cache?: Stack["cache"];
277
309
  /** The task queue; Huey where the cache is by default. */
@@ -287,6 +319,8 @@ interface ScaffoldOptions {
287
319
  /** Where the templates are; the ones in the package by default. */
288
320
  templatesDir?: string;
289
321
  }
322
+ /** A package manager of the frontend. */
323
+ type PackageManager = Stack["packageManager"];
290
324
  interface ScaffoldResult {
291
325
  directory: string;
292
326
  adminUsername: string;
@@ -295,6 +329,11 @@ interface ScaffoldResult {
295
329
  }
296
330
  /** The `.env` values of a cache and a task queue: CACHE_URL, TASKS_URL and TASK_WORKERS. */
297
331
  export declare function cacheEnv(stack: Pick<Stack, "cache" | "tasks">): Record<string, string>;
332
+ /**
333
+ * Adds or removes Content Studio's notifications app in the text of
334
+ * `backend/config/settings.py`. Throws when it can't find where it goes.
335
+ */
336
+ export declare function setNotificationsApp(settingsText: string, on: boolean): string;
298
337
  /** Whether `.env` values use the Valkey service (the `valkey` profile). */
299
338
  export declare function usesValkey(env: Record<string, string | undefined>): boolean;
300
339
  /** A valid project name from a directory name: `My Site` becomes `my-site`. */
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export { P as DEFAULT_STACK, i as FRONTENDS, F as NAMED_PRESETS, L as STACK_OPTIONS, v as VERSION, o as cacheEnv, z as decodePreset, B as encodePreset, b as generateProjectSecrets, x as generateSecret, l as normalizeDatabaseUrl, et as parseEnv, V as pendingChoices, n as productionEnv, H as resolvePreset, Y as runDoctor, d as scaffold, nt as setEnvValues, p as toProjectName, h as usesValkey, r as validateDomain, g as validateProjectName } from "./production-CGkI7oNe.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";
@@ -582,7 +582,7 @@ const STACK_OPTIONS = [
582
582
  value: "on",
583
583
  label: "On",
584
584
  hint: "in the admin and by email",
585
- generated: false
585
+ generated: true
586
586
  }, {
587
587
  value: "off",
588
588
  label: "Off",
@@ -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
  },
@@ -665,6 +667,33 @@ const STACK_OPTIONS = [
665
667
  hint: "any S3-compatible service; credentials later",
666
668
  generated: true
667
669
  }]
670
+ },
671
+ {
672
+ key: "errors",
673
+ flag: "--errors",
674
+ message: "Error reports",
675
+ description: "How you hear about errors in production",
676
+ bits: 2,
677
+ values: [
678
+ {
679
+ value: "email",
680
+ label: "Email",
681
+ hint: "to the superusers, and in the admin",
682
+ generated: true
683
+ },
684
+ {
685
+ value: "sentry",
686
+ label: "Sentry",
687
+ hint: "sentry.io, with SENTRY_DSN",
688
+ generated: true
689
+ },
690
+ {
691
+ value: "off",
692
+ label: "Off",
693
+ hint: "the logs only",
694
+ generated: true
695
+ }
696
+ ]
668
697
  }
669
698
  ];
670
699
  /**
@@ -945,7 +974,9 @@ function projectManifest(project) {
945
974
  function projectStack(project) {
946
975
  const fromEnv = {
947
976
  analytics: project.profiles.includes("analytics") ? "umami" : "off",
948
- ...project.env.CACHE_URL === void 0 ? {} : cacheStack(project.env)
977
+ ...project.env.CACHE_URL === void 0 ? {} : cacheStack(project.env),
978
+ ...notificationsStack(project),
979
+ ...captchaStack(project)
949
980
  };
950
981
  const { preset } = projectManifest(project);
951
982
  if (preset) try {
@@ -960,6 +991,23 @@ function projectStack(project) {
960
991
  storage: project.profiles.includes("s3") ? "s3" : "local"
961
992
  };
962
993
  }
994
+ /** Notifications are on when the backend's INSTALLED_APPS has Content Studio's notifications app. */
995
+ function notificationsStack(project) {
996
+ const settings = join(project.directory, "backend", "config", "settings.py");
997
+ if (!existsSync(settings)) return {};
998
+ return { notifications: /^ *["']content_studio\.notifications["'],/m.test(readFileSync(settings, "utf8")) ? "on" : "off" };
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
+ }
963
1011
  const REDIS = /^(redis|rediss|valkey|valkeys):/;
964
1012
  /**
965
1013
  * The cache and the tasks a project's `.env` runs. Projects from before 0.7
@@ -1113,6 +1161,56 @@ const PACKAGE_NAME = "@django-stack/cli";
1113
1161
  // src/scaffold.ts
1114
1162
  /** The frontends this version of the CLI generates. */
1115
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
+ }
1116
1214
  /** Umami's database URL: the project's database, with Umami's tables in the `umami` schema. */
1117
1215
  function umamiDatabaseUrl(databaseUrl) {
1118
1216
  const url = new URL(databaseUrl);
@@ -1155,6 +1253,25 @@ function cacheEnv(stack) {
1155
1253
  ...TASK_ENV[taskQueue(stack)]
1156
1254
  };
1157
1255
  }
1256
+ /** The line of Content Studio's notifications app in the backend's INSTALLED_APPS. */
1257
+ const NOTIFICATIONS_APP = " \"content_studio.notifications\", # notifications in the admin and by email\n";
1258
+ const NOTIFICATIONS_PATTERN = /^ *["']content_studio\.notifications["'],.*\n/m;
1259
+ /**
1260
+ * Adds or removes Content Studio's notifications app in the text of
1261
+ * `backend/config/settings.py`. Throws when it can't find where it goes.
1262
+ */
1263
+ function setNotificationsApp(settingsText, on) {
1264
+ const present = NOTIFICATIONS_PATTERN.test(settingsText);
1265
+ if (!on) return settingsText.replace(NOTIFICATIONS_PATTERN, "");
1266
+ if (present) return settingsText;
1267
+ const after = [
1268
+ /^ *["']content_studio\.contrib\.password_reset["'],.*\n/m,
1269
+ /^ *["']content_studio\.media_library["'],.*\n/m,
1270
+ /^ *["']content_studio["'],.*\n/m
1271
+ ].find((pattern) => pattern.test(settingsText));
1272
+ if (!after) throw new Error("backend/config/settings.py has no \"content_studio\" in INSTALLED_APPS; add \"content_studio.notifications\" there yourself.");
1273
+ return settingsText.replace(after, (line) => line + NOTIFICATIONS_APP);
1274
+ }
1158
1275
  /** Whether `.env` values use the Valkey service (the `valkey` profile). */
1159
1276
  function usesValkey(env) {
1160
1277
  return [env.CACHE_URL, env.TASKS_URL].some((url) => /^(redis|rediss|valkey|valkeys):\/\/cache[:/]/.test(url ?? ""));
@@ -1238,6 +1355,7 @@ function scaffold(options) {
1238
1355
  const about = join(target, "frontend", "template.json");
1239
1356
  const frontend = JSON.parse(readFileSync(about, "utf8"));
1240
1357
  rmSync(about);
1358
+ const packageManager = options.packageManager ?? "pnpm";
1241
1359
  const values = {
1242
1360
  project_name: options.name,
1243
1361
  frontend: options.frontend,
@@ -1247,7 +1365,8 @@ function scaffold(options) {
1247
1365
  cli_version: CLI_VERSION,
1248
1366
  preset: options.preset ?? encodePreset(DEFAULT_STACK),
1249
1367
  site_port: String(options.ports?.SITE_PORT ?? 3e3),
1250
- mail_port: String(options.ports?.MAIL_PORT ?? 8025)
1368
+ mail_port: String(options.ports?.MAIL_PORT ?? 8025),
1369
+ ...packageManagerValues(packageManager)
1251
1370
  };
1252
1371
  const files = walk(target);
1253
1372
  for (const file of files) {
@@ -1256,6 +1375,20 @@ function scaffold(options) {
1256
1375
  const rendered = render(text, values);
1257
1376
  if (rendered !== text) writeFileSync(file, rendered);
1258
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
+ }
1388
+ if (options.notifications === false) {
1389
+ const settings = join(target, "backend", "config", "settings.py");
1390
+ writeFileSync(settings, setNotificationsApp(readFileSync(settings, "utf8"), false));
1391
+ }
1259
1392
  if (options.timeZone && options.timeZone !== "UTC") {
1260
1393
  const settings = join(target, "backend", "config", "settings.py");
1261
1394
  const text = readFileSync(settings, "utf8");
@@ -1285,6 +1418,8 @@ function scaffold(options) {
1285
1418
  env = setEnvValues(env, { COMPOSE_PROFILES: profiles.join(",") });
1286
1419
  if (options.ports) env = setEnvValues(env, portValues(parseEnv(env), options.ports));
1287
1420
  if (!options.demoContent) env = setEnvValues(env, { SEED_DEMO_CONTENT: "0" });
1421
+ if (options.errors && options.errors !== "email") env = setEnvValues(env, { ERRORS: options.errors });
1422
+ if (options.captcha === false) env = setEnvValues(env, { CAPTCHA: "off" });
1288
1423
  writeFileSync(join(target, ".env"), env, { mode: 384 });
1289
1424
  writeFileSync(join(target, ".env.example"), envExample);
1290
1425
  rmSync(join(target, "env.example"));
@@ -1349,6 +1484,74 @@ function linkPackages(target, monorepo) {
1349
1484
  writeFileSync(packageJson, `${JSON.stringify(manifest, null, 2)}\n`);
1350
1485
  }
1351
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
+ }
1352
1555
  function lockBackend(target) {
1353
1556
  try {
1354
1557
  execFileSync("uv", ["lock", "--quiet"], {
@@ -1441,4 +1644,4 @@ function productionEnv(devEnv, options) {
1441
1644
  todo
1442
1645
  };
1443
1646
  }
1444
- export { isDockerDown as $, projectUrls as A, encodePreset as B, RUN_ENV as C, findProjectDirectory as D, composeOutput as E, NAMED_PRESETS as F, chooseFreePorts as G, resolvePreset as H, PROMPT_ORDER as I, portValues as J, describeMove as K, STACK_OPTIONS as L, readProject as M, runningServices as N, parseProfiles as O, DEFAULT_STACK as P, dockerRunning as Q, applies as R, RUN_DOMAIN as S, compose as T, stackOption as U, pendingChoices as V, DEV_PORTS as W, checkTools as X, runDoctor as Y, dockerNotRunning as Z, validateTimeZone as _, LOCAL_S3 as a, generateProjectSecrets as b, lockBackend as c, scaffold as d, parseEnv as et, timeZones as f, validateProjectName as g, usesValkey as h, FRONTENDS as i, readLocalRun as j, projectStack as k, normalizeDatabaseUrl as l, umamiEnv as m, productionEnv as n, setEnvValues as nt, cacheEnv as o, toProjectName as p, envPorts as q, validateDomain as r, localS3 as s, missingS3 as t, quoteEnvValue as tt, relativeDirectory as u, CLI_VERSION as v, activeProject as w, generateSecret as x, PACKAGE_NAME as y, decodePreset 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.8.1",
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",
@@ -20,16 +20,15 @@ WORKDIR /app
20
20
  FROM base AS deps
21
21
  COPY pyproject.toml uv.lock* ./
22
22
  COPY vendor/ vendor/
23
- RUN --mount=type=cache,target=/root/.cache/uv \
24
- uv sync --no-dev --no-install-project
23
+ # No BuildKit cache mounts: Railway rejects them, and the layer is cached anyway.
24
+ RUN uv sync --no-dev --no-install-project --no-cache
25
25
 
26
26
  # Development runs as root, so files the container writes into the mounted
27
27
  # source (such as new migrations) work on every host. Production does not.
28
28
  # The entrypoint runs with sh, so it works without the executable bit (the
29
29
  # mounted source keeps the host's file modes).
30
30
  FROM deps AS dev
31
- RUN --mount=type=cache,target=/root/.cache/uv \
32
- uv sync --no-install-project
31
+ RUN uv sync --no-install-project --no-cache
33
32
  ENTRYPOINT ["sh", "/app/entrypoint.sh"]
34
33
  CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
35
34
 
@@ -44,4 +43,4 @@ EXPOSE 8000
44
43
  HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=5 \
45
44
  CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/readyz', timeout=4)"
46
45
  ENTRYPOINT ["sh", "/app/entrypoint.sh"]
47
- CMD ["gunicorn", "config.asgi:application", "--worker-class", "uvicorn_worker.UvicornWorker", "--bind", "0.0.0.0:8000", "--workers", "3", "--access-logfile", "-", "--no-control-socket"]
46
+ CMD ["gunicorn", "config.asgi:application", "--worker-class", "uvicorn_worker.UvicornWorker", "--bind", "0.0.0.0:8000", "--workers", "3", "--no-control-socket"]
@@ -38,13 +38,16 @@ INSTALLED_APPS = [
38
38
  "content_studio",
39
39
  "content_studio.media_library",
40
40
  "content_studio.contrib.password_reset",
41
+ "content_studio.notifications", # notifications in the admin and by email
41
42
  "django_stack",
43
+ "django_stack.contrib.captcha", # the captcha of the contact form (CAPTCHA=off turns it off)
42
44
  "content",
43
45
  # Last: builds the API routes once every model is exposed.
44
46
  "headless.rest",
45
47
  ]
46
48
 
47
49
  MIDDLEWARE = [
50
+ "django_stack.logs.RequestIdMiddleware", # request IDs and the access log
48
51
  "django.middleware.security.SecurityMiddleware",
49
52
  "whitenoise.middleware.WhiteNoiseMiddleware",
50
53
  "django.contrib.sessions.middleware.SessionMiddleware",
@@ -10,12 +10,16 @@ from content_studio.contrib.huey import HueyAdapter
10
10
  from content_studio.dashboard.tasks import TaskDashboard
11
11
  from content_studio.extensions import UserMenuLink
12
12
  from django_stack.analytics import UmamiProvider
13
+ from django_stack.studio import SystemLink, SystemPage
13
14
 
14
15
 
15
16
  class SiteStudio(ContentStudioSite):
16
17
  extensions = [
17
18
  TaskDashboard(adapter=HueyAdapter()),
18
19
  UserMenuLink(url="/tasks/", label="Tasks", icon="ph-bold ph-queue"),
20
+ # Superusers only: the health checks and the latest error reports.
21
+ SystemPage(),
22
+ SystemLink(),
19
23
  ]
20
24
 
21
25
  # Page analytics from the bundled Umami when UMAMI_URL is set (see
@@ -1,6 +1,10 @@
1
- from django.apps import AppConfig
1
+ from django.apps import AppConfig, apps
2
2
 
3
3
 
4
4
  class ContentConfig(AppConfig):
5
5
  name = "content"
6
6
  verbose_name = "Content"
7
+
8
+ def ready(self):
9
+ if apps.is_installed("content_studio.notifications"):
10
+ from . import notifications # noqa: F401 - registers the events
@@ -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
@@ -0,0 +1,36 @@
1
+ """
2
+ The notifications of the site, with Content Studio's notifications app
3
+ (`content_studio.notifications` in INSTALLED_APPS). Editors get them in
4
+ the admin and, when they leave them unread, in an email digest. Each
5
+ editor picks which events they get on their settings page.
6
+
7
+ Register your own events here, and send them with notify(); see
8
+ https://djangocontentstudio.org for the API.
9
+ """
10
+
11
+ from content_studio.notifications import notify, register_event
12
+ from django.db.models.signals import post_save
13
+ from django.dispatch import receiver
14
+ from django.utils.translation import gettext_lazy as _
15
+
16
+ from .models import FormSubmission
17
+
18
+ FORM_SUBMITTED = register_event(
19
+ "site.form_submitted",
20
+ _("Contact form messages"),
21
+ _("A visitor sent a message with the contact form."),
22
+ )
23
+
24
+
25
+ @receiver(post_save, sender=FormSubmission, dispatch_uid="content_form_submitted")
26
+ def notify_form_submitted(sender, instance, created, raw=False, **kwargs):
27
+ """Every editor who may read the messages hears about a new one."""
28
+ if created and not raw:
29
+ notify(
30
+ FORM_SUBMITTED.key,
31
+ title=_("Message from %(name)s"),
32
+ # The visitor's text goes in params: a "%" in it stays text.
33
+ body=_("%(message)s"),
34
+ params={"name": instance.name, "message": instance.message[:200]},
35
+ target=instance,
36
+ )
@@ -1,11 +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
+ from django.apps import apps
8
9
  from django.conf import settings
10
+ from django_stack.contrib.captcha.testing import captcha_token
9
11
  from rest_framework.test import APIClient
10
12
 
11
13
  from content.models import FormSubmission, Page, Post
@@ -62,12 +64,40 @@ def test_post_by_slug(reader, seeded):
62
64
  def test_contact_form(seeded):
63
65
  response = APIClient().post(
64
66
  "/api/content.formsubmission",
65
- {"name": "Ada", "email": "ada@example.com", "message": "Hello"},
67
+ {"name": "Ada", "email": "ada@example.com", "message": "Hello", "captcha": captcha_token()},
66
68
  format="json",
67
69
  )
68
70
  assert response.status_code == 201
69
71
  assert FormSubmission.objects.filter(email="ada@example.com").exists()
70
72
 
71
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
+
85
+ @pytest.mark.skipif(not apps.is_installed("content_studio.notifications"), reason="notifications are off")
86
+ def test_contact_form_notifies_the_editors(seeded, django_capture_on_commit_callbacks):
87
+ from content_studio.notifications.models import Notification
88
+ from django.contrib.auth import get_user_model
89
+
90
+ editor = get_user_model().objects.create_superuser("editor", "editor@example.com", "x")
91
+ with django_capture_on_commit_callbacks(execute=True):
92
+ APIClient().post(
93
+ "/api/content.formsubmission",
94
+ {"name": "Ada", "email": "ada@example.com", "message": "100% sure", "captcha": captcha_token()},
95
+ format="json",
96
+ )
97
+ notification = Notification.objects.get(recipient=editor, event="site.form_submitted")
98
+ assert notification.get_title() == "Message from Ada"
99
+ assert notification.get_body() == "100% sure"
100
+
101
+
72
102
  def test_read_key_cannot_read_submissions(reader):
73
103
  assert reader.get("/api/content.formsubmission").status_code == 403
@@ -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.8.1",
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