@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.
- package/dist/bin.js +840 -54
- package/dist/index.d.ts +43 -4
- package/dist/index.js +1 -1
- package/dist/{production-CGkI7oNe.js → production-Ct_qoeXk.js} +210 -7
- package/package.json +1 -1
- package/templates/backend/Dockerfile +4 -5
- package/templates/backend/config/settings.py +3 -0
- package/templates/backend/config/studio.py +4 -0
- package/templates/backend/content/apps.py +5 -1
- package/templates/backend/content/models.py +2 -2
- package/templates/backend/content/notifications.py +36 -0
- package/templates/backend/content/tests/test_api.py +32 -2
- package/templates/backend/pyproject.toml +1 -1
- package/templates/base/AGENTS.md +8 -8
- package/templates/base/Caddyfile +7 -1
- package/templates/base/README.md +3 -3
- package/templates/base/compose.yaml +37 -2
- package/templates/base/env.example +29 -0
- package/templates/frontend-astro/AGENTS.md +3 -3
- package/templates/frontend-astro/Dockerfile +7 -4
- package/templates/frontend-astro/astro.config.mjs +16 -5
- package/templates/frontend-astro/package.json +1 -1
- package/templates/frontend-astro/pnpm-workspace.yaml +17 -0
- package/templates/frontend-astro/src/components/ContactForm.astro +19 -3
- package/templates/frontend-astro/src/middleware.ts +9 -1
- package/templates/frontend-next/AGENTS.md +3 -3
- package/templates/frontend-next/Dockerfile +6 -3
- package/templates/frontend-next/package.json +3 -3
- package/templates/frontend-next/pnpm-workspace.yaml +11 -0
- package/templates/frontend-next/src/app/[...path]/page.tsx +2 -1
- package/templates/frontend-next/src/components/ContactForm.tsx +14 -3
- package/templates/frontend-next/src/instrumentation.ts +12 -0
- package/templates/frontend-next/src/proxy.ts +4 -1
- package/templates/frontend-nuxt/AGENTS.md +3 -3
- package/templates/frontend-nuxt/Dockerfile +6 -3
- package/templates/frontend-nuxt/nuxt.config.ts +7 -3
- package/templates/frontend-nuxt/package.json +2 -2
- package/templates/frontend-nuxt/pnpm-workspace.yaml +13 -0
- package/templates/frontend-nuxt/server/plugins/errors.ts +14 -0
- package/templates/frontend-nuxt/server/routes/_content/layout.get.ts +4 -0
- package/templates/frontend-nuxt/src/components/ContactForm.vue +15 -2
- package/templates/frontend-sveltekit/AGENTS.md +3 -3
- package/templates/frontend-sveltekit/Dockerfile +7 -4
- package/templates/frontend-sveltekit/package.json +2 -2
- package/templates/frontend-sveltekit/pnpm-workspace.yaml +11 -0
- package/templates/frontend-sveltekit/src/hooks.server.ts +13 -1
- package/templates/frontend-sveltekit/src/lib/components/ContactForm.svelte +15 -2
- package/templates/frontend-sveltekit/src/routes/+layout.server.ts +3 -0
- package/templates/frontend-sveltekit/src/routes/[...path]/+page.svelte +1 -1
- 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:
|
|
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:
|
|
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
|
|
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
|
|
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 {
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
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
|
-
|
|
24
|
-
|
|
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 --
|
|
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", "--
|
|
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 ``
|
|
9
|
-
and ``
|
|
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 `
|
|
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
|
+
"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
|
]
|
package/templates/base/AGENTS.md
CHANGED
|
@@ -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 `
|
|
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 `
|
|
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 `
|
|
34
|
-
| Run a manage.py command | `docker compose exec backend python manage.py <command>` (or `
|
|
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 | `
|
|
37
|
-
| Run the tests | `
|
|
38
|
-
| Lint and type-check | `
|
|
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 `
|
|
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
|
|