@django-stack/cli 0.6.0 → 0.7.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.
@@ -4,6 +4,7 @@ import { join, relative } from "node:path";
4
4
  import { execFileSync } from "node:child_process";
5
5
  import { randomBytes, randomUUID } from "node:crypto";
6
6
  import { fileURLToPath } from "node:url";
7
+ import { createConnection, createServer } from "node:net";
7
8
  // src/env.ts
8
9
  /** Reads a .env file: KEY=value lines, comments and blank lines ignored. */
9
10
  function parseEnv(text) {
@@ -28,6 +29,18 @@ function setEnvValues(text, values) {
28
29
  }
29
30
  return result;
30
31
  }
32
+ /**
33
+ * A value as Docker Compose reads it literally: single-quoted when it has
34
+ * characters Compose would interpolate or cut, such as `$`, `#` or a
35
+ * space at either end.
36
+ * Throws for a value it cannot quote.
37
+ */
38
+ function quoteEnvValue(value) {
39
+ if (/[\r\n]/.test(value)) throw new Error("A value can't have a line break.");
40
+ if (!/[$#"`\\]|^\s|\s$|^'/.test(value)) return value;
41
+ if (value.includes("'")) throw new Error("A value with ' and other special characters can't be quoted.");
42
+ return `'${value}'`;
43
+ }
31
44
  /** The oldest Python the backend runs on. */
32
45
  const MIN_PYTHON = "3.12";
33
46
  /** Runs a command and returns its trimmed output, or undefined when it fails. */
@@ -66,7 +79,7 @@ function dockerHelp(platform) {
66
79
  if (platform === "win32") return ["Install Docker Desktop: winget install Docker.DockerDesktop", "or https://docs.docker.com/desktop/setup/install/windows-install/"];
67
80
  return ["Install Docker Engine and the Compose plugin: https://docs.docker.com/engine/install/"];
68
81
  }
69
- function dockerStartHelp(platform) {
82
+ function dockerStartHelp(platform = process.platform) {
70
83
  if (platform === "linux") return ["Start it: sudo systemctl start docker"];
71
84
  return ["Open Docker Desktop and wait until it says it is running."];
72
85
  }
@@ -88,6 +101,22 @@ function pythonVersion(runCommand) {
88
101
  if (version) return version;
89
102
  }
90
103
  }
104
+ /** The one-line error when the Docker daemon is not reachable. */
105
+ function dockerNotRunning(platform = process.platform) {
106
+ return `Docker not running. ${dockerStartHelp(platform)[0]}`;
107
+ }
108
+ /** Whether Docker's output says the daemon is not reachable, such as `failed to connect to the docker API`. */
109
+ function isDockerDown(output) {
110
+ return /failed to connect to the docker API|Cannot connect to the Docker daemon|Is the docker daemon running|error during connect|docker_engine: The system cannot find the file specified/i.test(output);
111
+ }
112
+ /** Whether the Docker daemon answers. */
113
+ function dockerRunning(runCommand = run) {
114
+ return Boolean(runCommand("docker", [
115
+ "version",
116
+ "--format",
117
+ "{{.Server.Version}}"
118
+ ]));
119
+ }
91
120
  /** Docker and Docker Compose, which run the project. */
92
121
  function checkDocker({ run: runCommand = run, platform = process.platform } = {}) {
93
122
  const client = parseVersion(runCommand("docker", ["--version"]));
@@ -269,6 +298,144 @@ async function runDoctor(directory) {
269
298
  });
270
299
  return checks;
271
300
  }
301
+ // src/ports.ts
302
+ const DEV_PORTS = [
303
+ {
304
+ name: "SITE_PORT",
305
+ port: 3e3,
306
+ label: "site"
307
+ },
308
+ {
309
+ name: "BACKEND_PORT",
310
+ port: 8e3,
311
+ label: "backend"
312
+ },
313
+ {
314
+ name: "MAIL_PORT",
315
+ port: 8025,
316
+ label: "Mailpit"
317
+ },
318
+ {
319
+ name: "S3_PORT",
320
+ port: 8333,
321
+ label: "local S3 service",
322
+ profile: "s3"
323
+ }
324
+ ];
325
+ /** How many ports above the usual one to try. */
326
+ const TRIES = 100;
327
+ /** Whether something answers on 127.0.0.1:port. */
328
+ function answers(port) {
329
+ return new Promise((resolve) => {
330
+ const socket = createConnection({
331
+ host: "127.0.0.1",
332
+ port
333
+ });
334
+ const done = (result) => {
335
+ socket.destroy();
336
+ resolve(result);
337
+ };
338
+ socket.setTimeout(500, () => done(false));
339
+ socket.once("connect", () => done(true));
340
+ socket.once("error", () => done(false));
341
+ });
342
+ }
343
+ /** Whether this process can listen on 127.0.0.1:port. */
344
+ function canListen(port) {
345
+ return new Promise((resolve) => {
346
+ const server = createServer();
347
+ server.once("error", () => resolve(false));
348
+ server.listen({
349
+ host: "127.0.0.1",
350
+ port,
351
+ exclusive: true
352
+ }, () => server.close(() => resolve(true)));
353
+ });
354
+ }
355
+ /**
356
+ * Whether a port is free for Docker to publish on 127.0.0.1. Both checks,
357
+ * because on macOS a server on all addresses does not stop a listen on
358
+ * 127.0.0.1, but it does answer there.
359
+ */
360
+ const isPortFree = async (port) => !await answers(port) && await canListen(port);
361
+ /** The first free port from `preferred` up, leaving out `taken`. */
362
+ async function freePort(preferred, taken = /* @__PURE__ */ new Set(), isFree = isPortFree) {
363
+ for (let port = preferred; port < preferred + TRIES && port <= 65535; port++) if (!taken.has(port) && await isFree(port)) return port;
364
+ }
365
+ /** The ports of a `.env`, by variable. */
366
+ function envPorts(env) {
367
+ return Object.fromEntries(DEV_PORTS.map((item) => {
368
+ const value = Number(env[item.name]);
369
+ return [item.name, Number.isInteger(value) && value > 0 ? value : item.port];
370
+ }));
371
+ }
372
+ /**
373
+ * The `.env` values for new ports: the ports, and the URLs on this machine
374
+ * that contain them (SITE_URL and BACKEND_URL, and S3_PUBLIC_URL of the
375
+ * local S3 service).
376
+ */
377
+ function portValues(env, ports) {
378
+ const before = envPorts(env);
379
+ const values = {};
380
+ for (const item of DEV_PORTS) {
381
+ const port = ports[item.name];
382
+ if (port !== void 0 && port !== before[item.name]) values[item.name] = String(port);
383
+ }
384
+ const replacePort = (url, from, to) => {
385
+ if (!url || from === void 0 || to === void 0 || from === to) return void 0;
386
+ const local = new RegExp(`^(https?://(?:localhost|127\\.0\\.0\\.1)):${from}(?=/|$)`);
387
+ return local.test(url) ? url.replace(local, `$1:${to}`) : void 0;
388
+ };
389
+ for (const [name, port] of [
390
+ ["SITE_URL", "SITE_PORT"],
391
+ ["BACKEND_URL", "SITE_PORT"],
392
+ ["S3_PUBLIC_URL", "S3_PORT"]
393
+ ]) {
394
+ const url = replacePort(env[name], before[port], ports[port]);
395
+ if (url) values[name] = url;
396
+ }
397
+ return values;
398
+ }
399
+ /**
400
+ * Free ports for the development services of a `.env`: the ones it has
401
+ * when they are free, otherwise the next free ones up. Returns the moves,
402
+ * or the ports that have no free one nearby.
403
+ */
404
+ async function chooseFreePorts(env, profiles, isFree = isPortFree) {
405
+ const ports = envPorts(env);
406
+ const moves = [];
407
+ const stuck = [];
408
+ const taken = /* @__PURE__ */ new Set();
409
+ for (const item of DEV_PORTS) {
410
+ const current = ports[item.name];
411
+ if (item.profile && !profiles.includes(item.profile)) {
412
+ taken.add(current);
413
+ continue;
414
+ }
415
+ const port = !taken.has(current) && await isFree(current) ? current : await freePort(current + 1, taken, isFree);
416
+ if (port === void 0) {
417
+ stuck.push(item);
418
+ taken.add(current);
419
+ continue;
420
+ }
421
+ if (port !== current) moves.push({
422
+ label: item.label,
423
+ from: current,
424
+ to: port
425
+ });
426
+ ports[item.name] = port;
427
+ taken.add(port);
428
+ }
429
+ return {
430
+ ports,
431
+ moves,
432
+ stuck
433
+ };
434
+ }
435
+ /** A line for a port that moved, such as `Port 3000 is in use, so the site uses 3001.` */
436
+ function describeMove(move) {
437
+ return `Port ${move.from} is in use, so the ${move.label} uses ${move.to}.`;
438
+ }
272
439
  // src/secrets.ts
273
440
  /** A URL-safe random secret with `bytes` bytes of randomness. */
274
441
  function generateSecret(bytes = 32) {
@@ -295,6 +462,7 @@ const STACK_OPTIONS = [
295
462
  flag: "--frontend",
296
463
  message: "Frontend",
297
464
  description: "The frontend framework",
465
+ bits: 4,
298
466
  values: [
299
467
  {
300
468
  value: "astro",
@@ -339,6 +507,7 @@ const STACK_OPTIONS = [
339
507
  flag: "--postgres",
340
508
  message: "Postgres version",
341
509
  description: "The bundled Postgres version",
510
+ bits: 2,
342
511
  values: [{
343
512
  value: "stable",
344
513
  label: "Stable",
@@ -356,6 +525,7 @@ const STACK_OPTIONS = [
356
525
  flag: "--analytics",
357
526
  message: "Analytics",
358
527
  description: "Page analytics",
528
+ bits: 2,
359
529
  values: [{
360
530
  value: "umami",
361
531
  label: "Umami",
@@ -372,39 +542,52 @@ const STACK_OPTIONS = [
372
542
  flag: "--tasks",
373
543
  message: "Background tasks",
374
544
  description: "The task queue",
375
- values: [{
376
- value: "huey",
377
- label: "Huey",
378
- hint: "a worker container with Valkey",
379
- generated: true
380
- }, {
381
- value: "off",
382
- label: "Off",
383
- generated: false
384
- }]
545
+ bits: 2,
546
+ values: [
547
+ {
548
+ value: "huey",
549
+ label: "Huey",
550
+ hint: "a worker container, the queue where the cache is",
551
+ generated: true
552
+ },
553
+ {
554
+ value: "off",
555
+ label: "Off",
556
+ hint: "tasks run in the request, no daily backups",
557
+ generated: true
558
+ },
559
+ {
560
+ value: "huey-valkey",
561
+ label: "Huey on Valkey",
562
+ hint: "the queue in Valkey",
563
+ generated: true
564
+ }
565
+ ]
385
566
  },
386
567
  {
387
568
  key: "cache",
388
569
  flag: "--cache",
389
570
  message: "Cache",
390
571
  description: "The cache backend",
572
+ bits: 3,
391
573
  values: [
392
574
  {
393
575
  value: "database",
394
576
  label: "Database",
395
577
  hint: "a Postgres table, nothing extra to run",
396
- generated: false
578
+ generated: true
397
579
  },
398
580
  {
399
581
  value: "valkey",
400
582
  label: "Valkey",
401
583
  hint: "in memory, Redis-compatible",
402
- generated: false
584
+ generated: true
403
585
  },
404
586
  {
405
587
  value: "off",
406
588
  label: "Off",
407
- generated: false
589
+ hint: "a cache per process",
590
+ generated: true
408
591
  }
409
592
  ]
410
593
  },
@@ -413,6 +596,7 @@ const STACK_OPTIONS = [
413
596
  flag: "--notifications",
414
597
  message: "Notifications",
415
598
  description: "Notifications in the admin and by email",
599
+ bits: 2,
416
600
  values: [{
417
601
  value: "on",
418
602
  label: "On",
@@ -429,6 +613,7 @@ const STACK_OPTIONS = [
429
613
  flag: "--captcha",
430
614
  message: "Captcha",
431
615
  description: "The built-in captcha for forms",
616
+ bits: 2,
432
617
  values: [{
433
618
  value: "on",
434
619
  label: "On",
@@ -445,6 +630,7 @@ const STACK_OPTIONS = [
445
630
  flag: "--storage",
446
631
  message: "Uploads in development",
447
632
  description: "Where uploads go in development",
633
+ bits: 2,
448
634
  values: [{
449
635
  value: "local",
450
636
  label: "Local files",
@@ -462,6 +648,7 @@ const STACK_OPTIONS = [
462
648
  flag: "--package-manager",
463
649
  message: "Package manager",
464
650
  description: "The frontend's package manager",
651
+ bits: 3,
465
652
  values: [
466
653
  {
467
654
  value: "pnpm",
@@ -479,14 +666,49 @@ const STACK_OPTIONS = [
479
666
  generated: false
480
667
  }
481
668
  ]
669
+ },
670
+ {
671
+ key: "media",
672
+ flag: "--media",
673
+ message: "Uploads in production",
674
+ description: "Where uploads go in production",
675
+ bits: 2,
676
+ values: [{
677
+ value: "server",
678
+ label: "On the server",
679
+ hint: "a Docker volume, backed up with the database",
680
+ generated: true
681
+ }, {
682
+ value: "s3",
683
+ label: "S3",
684
+ hint: "any S3-compatible service; credentials later",
685
+ generated: true
686
+ }]
482
687
  }
483
688
  ];
689
+ /**
690
+ * The order of the prompts. It differs from STACK_OPTIONS (the preset order,
691
+ * append-only): the cache comes before the tasks, whose queue follows it,
692
+ * and uploads in production before uploads in development.
693
+ */
694
+ const PROMPT_ORDER = (() => {
695
+ const keys = STACK_OPTIONS.map((option) => option.key).filter((key) => key !== "media" && key !== "cache");
696
+ keys.splice(keys.indexOf("storage"), 0, "media");
697
+ keys.splice(keys.indexOf("tasks"), 0, "cache");
698
+ return keys;
699
+ })();
484
700
  function stackOption(key) {
485
701
  const option = STACK_OPTIONS.find((candidate) => candidate.key === key);
486
702
  if (!option) throw new Error(`Unknown stack option ${key}`);
487
703
  return option;
488
704
  }
489
705
  const DEFAULT_STACK = Object.fromEntries(STACK_OPTIONS.map((option) => [option.key, option.values[0].value]));
706
+ /** Where the task queue of a stack is: the database, Valkey, or none (tasks run in the request). */
707
+ function taskQueue(stack) {
708
+ if (stack.tasks === "off") return "none";
709
+ if (stack.tasks === "huey-valkey" || stack.cache === "valkey") return "valkey";
710
+ return "database";
711
+ }
490
712
  /** The frontends that run on Node, with a `frontend/` directory and a package manager. */
491
713
  const NODE_FRONTENDS = [
492
714
  "astro",
@@ -499,26 +721,68 @@ function applies(key, stack) {
499
721
  if (key === "packageManager") return stack.frontend === void 0 || NODE_FRONTENDS.includes(stack.frontend);
500
722
  return true;
501
723
  }
502
- /** The preset format version, the first character of a code. */
503
- const VERSION = "a";
504
724
  const DIGITS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
505
- /** A preset code for a stack: the version, then one character per option, trailing defaults left out. */
725
+ /**
726
+ * Preset codes are six characters: the format version, then the stack as
727
+ * one number in five base62 digits. The options are bit fields of that
728
+ * number, the first option in the lowest bits; the bits above the last
729
+ * option are for options to come and must be 0.
730
+ *
731
+ * The number is multiplied by MULTIPLIER and offset by OFFSET (modulo
732
+ * 62^5) before it is written, so codes look different from each other
733
+ * rather than mostly zeros, and a mistyped code is usually refused instead
734
+ * of meaning another stack. Both constants are part of the format: never
735
+ * change them.
736
+ */
737
+ const VERSION = "b";
738
+ /** The first format: one character per option, trailing defaults left out. */
739
+ const VERSION_A = "a";
740
+ const LENGTH = 5;
741
+ const MODULUS = 62n ** BigInt(LENGTH);
742
+ /** The bits a code can hold: 2^29 is below 62^5. */
743
+ const CAPACITY = 29;
744
+ const MULTIPLIER = 387420489n;
745
+ const OFFSET = 578291047n;
746
+ /** The inverse of `value` modulo `modulus`. */
747
+ function inverse(value, modulus) {
748
+ let [a, b, x, y] = [
749
+ value % modulus,
750
+ modulus,
751
+ 1n,
752
+ 0n
753
+ ];
754
+ while (b) {
755
+ const q = a / b;
756
+ [a, b] = [b, a - q * b];
757
+ [x, y] = [y, x - q * y];
758
+ }
759
+ return (x % modulus + modulus) % modulus;
760
+ }
761
+ const MULTIPLIER_INVERSE = inverse(MULTIPLIER, MODULUS);
762
+ /** A preset code for a stack: always six characters, such as `b8Kq2x`. */
506
763
  function encodePreset(stack) {
507
- const digits = STACK_OPTIONS.map((option) => {
764
+ let number = 0n;
765
+ let shift = 0n;
766
+ for (const option of STACK_OPTIONS) {
508
767
  const index = option.values.findIndex((value) => value.value === stack[option.key]);
509
768
  if (index < 0) throw new Error(`${JSON.stringify(stack[option.key])} is not a ${option.key}.`);
510
- return DIGITS[index];
511
- }).join("").replace(/0+$/, "");
769
+ number |= BigInt(index) << shift;
770
+ shift += BigInt(option.bits);
771
+ }
772
+ let scrambled = (number * MULTIPLIER + OFFSET) % MODULUS;
773
+ let digits = "";
774
+ for (let position = 0; position < LENGTH; position++) {
775
+ digits = DIGITS[Number(scrambled % 62n)] + digits;
776
+ scrambled /= 62n;
777
+ }
512
778
  return VERSION + digits;
513
779
  }
514
780
  const UPGRADE = "This preset needs a newer CLI. Run npx @django-stack/cli@latest init.";
515
- /** The stack of a preset code. Throws with a one-line reason for a bad code. */
516
- function decodePreset(code) {
517
- if (!/^[0-9a-zA-Z]+$/.test(code)) throw new Error(`${JSON.stringify(code)} is not a preset.`);
518
- if (code[0] !== VERSION) {
519
- if (DIGITS.indexOf(code[0] ?? "") > DIGITS.indexOf(VERSION)) throw new Error(UPGRADE);
520
- throw new Error(`${JSON.stringify(code)} is not a preset.`);
521
- }
781
+ function notAPreset(code) {
782
+ return /* @__PURE__ */ new Error(`${JSON.stringify(code)} is not a preset. Check the code, or run npx @django-stack/cli@latest init if it is from a newer CLI.`);
783
+ }
784
+ /** The stack of a code in the first format, which `init` wrote before 0.7. */
785
+ function decodeVersionA(code) {
522
786
  const digits = code.slice(1);
523
787
  if (digits.length > STACK_OPTIONS.length) throw new Error(UPGRADE);
524
788
  const stack = { ...DEFAULT_STACK };
@@ -530,6 +794,31 @@ function decodePreset(code) {
530
794
  });
531
795
  return stack;
532
796
  }
797
+ /** The stack of a preset code. Throws with a one-line reason for a bad code. */
798
+ function decodePreset(code) {
799
+ if (!/^[0-9a-zA-Z]+$/.test(code)) throw new Error(`${JSON.stringify(code)} is not a preset.`);
800
+ if (code[0] === VERSION_A) return decodeVersionA(code);
801
+ if (code[0] !== VERSION) {
802
+ if (DIGITS.indexOf(code[0] ?? "") > DIGITS.indexOf(VERSION)) throw new Error(UPGRADE);
803
+ throw new Error(`${JSON.stringify(code)} is not a preset.`);
804
+ }
805
+ if (code.length !== 6) throw notAPreset(code);
806
+ let scrambled = 0n;
807
+ for (const digit of code.slice(1)) scrambled = scrambled * 62n + BigInt(DIGITS.indexOf(digit));
808
+ let number = ((scrambled - OFFSET) % MODULUS + MODULUS) % MODULUS;
809
+ number = number * MULTIPLIER_INVERSE % MODULUS;
810
+ if (number >> BigInt(CAPACITY)) throw notAPreset(code);
811
+ const stack = {};
812
+ for (const option of STACK_OPTIONS) {
813
+ const index = Number(number & (1n << BigInt(option.bits)) - 1n);
814
+ number >>= BigInt(option.bits);
815
+ const value = option.values[index];
816
+ if (!value) throw notAPreset(code);
817
+ stack[option.key] = value.value;
818
+ }
819
+ if (number) throw notAPreset(code);
820
+ return stack;
821
+ }
533
822
  const NAMED_PRESETS = {
534
823
  blog: {
535
824
  description: "Astro with everything on (the defaults)",
@@ -598,16 +887,49 @@ function umamiEnv(databaseUrl, secrets) {
598
887
  UMAMI_DATABASE_URL: umamiDatabaseUrl(databaseUrl)
599
888
  };
600
889
  }
601
- /** The local S3 service of the `s3` profile. It accepts any credentials. */
602
- const LOCAL_S3 = {
603
- S3_BUCKET: "media",
604
- S3_ENDPOINT_URL: "http://s3:8333",
605
- S3_REGION: "us-east-1",
606
- S3_ACCESS_KEY_ID: "local",
607
- S3_SECRET_ACCESS_KEY: "local",
608
- S3_PUBLIC_URL: "http://localhost:8333/media",
609
- S3_CREATE_BUCKET: "1"
890
+ const CACHE_URLS = {
891
+ database: "db://",
892
+ valkey: "redis://cache:6379/0",
893
+ off: "locmem://"
894
+ };
895
+ const TASK_ENV = {
896
+ database: {
897
+ TASKS_URL: "db://",
898
+ TASK_WORKERS: "1"
899
+ },
900
+ valkey: {
901
+ TASKS_URL: "redis://cache:6379/1",
902
+ TASK_WORKERS: "1"
903
+ },
904
+ none: {
905
+ TASKS_URL: "immediate://",
906
+ TASK_WORKERS: "0"
907
+ }
610
908
  };
909
+ /** The `.env` values of a cache and a task queue: CACHE_URL, TASKS_URL and TASK_WORKERS. */
910
+ function cacheEnv(stack) {
911
+ return {
912
+ CACHE_URL: CACHE_URLS[stack.cache],
913
+ ...TASK_ENV[taskQueue(stack)]
914
+ };
915
+ }
916
+ /** Whether `.env` values use the Valkey service (the `valkey` profile). */
917
+ function usesValkey(env) {
918
+ return [env.CACHE_URL, env.TASKS_URL].some((url) => /^(redis|rediss|valkey|valkeys):\/\/cache[:/]/.test(url ?? ""));
919
+ }
920
+ /** The local S3 service of the `s3` profile, published on `port`. It accepts any credentials. */
921
+ function localS3(port = 8333) {
922
+ return {
923
+ S3_BUCKET: "media",
924
+ S3_ENDPOINT_URL: "http://s3:8333",
925
+ S3_REGION: "us-east-1",
926
+ S3_ACCESS_KEY_ID: "local",
927
+ S3_SECRET_ACCESS_KEY: "local",
928
+ S3_PUBLIC_URL: `http://localhost:${port}/media`,
929
+ S3_CREATE_BUCKET: "1"
930
+ };
931
+ }
932
+ const LOCAL_S3 = localS3();
611
933
  const TEXT_EXTENSIONS = /\.(py|ts|tsx|mjs|js|jsx|json|astro|vue|svelte|css|md|yaml|yml|toml|txt|html|sh|svg)$|Dockerfile$|gitignore$|\.dockerignore$|env\.example$/;
612
934
  function defaultTemplatesDir() {
613
935
  return fileURLToPath(new URL("../templates", import.meta.url));
@@ -680,7 +1002,9 @@ function scaffold(options) {
680
1002
  frontend_url: frontend.url,
681
1003
  frontend_security: frontend.security,
682
1004
  cli_version: CLI_VERSION,
683
- preset: options.preset ?? "a"
1005
+ preset: options.preset ?? encodePreset(DEFAULT_STACK),
1006
+ site_port: String(options.ports?.SITE_PORT ?? 3e3),
1007
+ mail_port: String(options.ports?.MAIL_PORT ?? 8025)
684
1008
  };
685
1009
  const files = walk(target);
686
1010
  for (const file of files) {
@@ -709,7 +1033,14 @@ function scaffold(options) {
709
1033
  profiles.push("analytics");
710
1034
  env = setEnvValues(env, umamiEnv(parseEnv(env).DATABASE_URL ?? "", secrets));
711
1035
  }
1036
+ const cache = cacheEnv({
1037
+ cache: options.cache ?? "database",
1038
+ tasks: options.tasks ?? "huey"
1039
+ });
1040
+ env = setEnvValues(env, cache);
1041
+ if (usesValkey(cache)) profiles.push("valkey");
712
1042
  env = setEnvValues(env, { COMPOSE_PROFILES: profiles.join(",") });
1043
+ if (options.ports) env = setEnvValues(env, portValues(parseEnv(env), options.ports));
713
1044
  if (!options.demoContent) env = setEnvValues(env, { SEED_DEMO_CONTENT: "0" });
714
1045
  writeFileSync(join(target, ".env"), env, { mode: 384 });
715
1046
  writeFileSync(join(target, ".env.example"), envExample);
@@ -791,6 +1122,15 @@ function relativeDirectory(directory) {
791
1122
  return path.startsWith("..") ? directory : path;
792
1123
  }
793
1124
  // src/production.ts
1125
+ /** The S3 settings that uploads to S3 need and that are empty. */
1126
+ function missingS3(env) {
1127
+ return [
1128
+ "S3_BUCKET",
1129
+ "S3_REGION",
1130
+ "S3_ACCESS_KEY_ID",
1131
+ "S3_SECRET_ACCESS_KEY"
1132
+ ].filter((name) => !env[name]);
1133
+ }
794
1134
  function validateDomain(domain) {
795
1135
  if (!/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i.test(domain)) return "Enter a domain name such as example.com, without https://.";
796
1136
  }
@@ -847,8 +1187,9 @@ function productionEnv(devEnv, options) {
847
1187
  S3_PUBLIC_URL: "",
848
1188
  S3_CREATE_BUCKET: "0"
849
1189
  });
850
- todo.push("Set the S3_* variables to your S3 service, or leave them empty to keep uploads on the server.");
1190
+ if (options.media !== "s3") todo.push("Set the S3_* variables to your S3 service, or leave them empty to keep uploads on the server.");
851
1191
  }
1192
+ if (options.media === "s3" && missingS3(parseEnv(setEnvValues(devEnv, values))).length) todo.push("Set the S3_* variables to your S3 service: uploads go to S3 in production.");
852
1193
  if (!current.ADMIN_EMAIL || current.ADMIN_EMAIL.endsWith("@example.com")) todo.push("Set ADMIN_EMAIL to your address.");
853
1194
  if (current.SEED_DEMO_CONTENT === "1") todo.push("Set SEED_DEMO_CONTENT=0 unless the site should start with the demo pages and posts.");
854
1195
  return {
@@ -857,4 +1198,4 @@ function productionEnv(devEnv, options) {
857
1198
  todo
858
1199
  };
859
1200
  }
860
- export { stackOption as C, checkTools as D, runDoctor as E, parseEnv as O, resolvePreset as S, generateSecret as T, STACK_OPTIONS as _, lockBackend as a, encodePreset as b, scaffold as c, umamiEnv as d, validateProjectName as f, NAMED_PRESETS as g, DEFAULT_STACK as h, LOCAL_S3 as i, setEnvValues as k, timeZones as l, CLI_VERSION as m, validateDomain as n, normalizeDatabaseUrl as o, validateTimeZone as p, FRONTENDS as r, relativeDirectory as s, productionEnv as t, toProjectName as u, applies as v, generateProjectSecrets as w, pendingChoices as x, decodePreset as y };
1201
+ export { generateSecret as A, isDockerDown as B, applies as C, resolvePreset as D, pendingChoices as E, portValues as F, quoteEnvValue as H, runDoctor as I, checkTools as L, chooseFreePorts as M, describeMove as N, stackOption as O, envPorts as P, dockerNotRunning as R, STACK_OPTIONS as S, encodePreset as T, setEnvValues as U, parseEnv as V, validateTimeZone as _, LOCAL_S3 as a, NAMED_PRESETS as b, lockBackend as c, scaffold as d, timeZones as f, validateProjectName as g, usesValkey as h, FRONTENDS as i, DEV_PORTS as j, generateProjectSecrets as k, normalizeDatabaseUrl as l, umamiEnv as m, productionEnv as n, cacheEnv as o, toProjectName as p, validateDomain as r, localS3 as s, missingS3 as t, relativeDirectory as u, CLI_VERSION as v, decodePreset as w, PROMPT_ORDER as x, DEFAULT_STACK as y, dockerRunning as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@django-stack/cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",
@@ -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.6.0",
8
+ "django-stack[server,s3]>=0.7.0",
9
9
  "django-headless[schema]>=1.1.1",
10
10
  "django-content-studio[images,huey]>=1.11.0",
11
11
  ]
@@ -6,7 +6,9 @@ A website with a Django backend and a frontend built with {{frontend_label}}, cr
6
6
  - [Django Headless](https://djangoheadless.org) serves the content API.
7
7
  - [Django Content Studio](https://djangocontentstudio.org) is the admin.
8
8
  - [{{frontend_label}}]({{frontend_url}}) renders the site.
9
- - Postgres stores the content; Valkey is the cache and task queue.
9
+ - Postgres stores the content, and by default also the cache and the task
10
+ queue (`CACHE_URL` and `TASKS_URL` in `.env`); Valkey can take those over
11
+ with the `valkey` profile.
10
12
 
11
13
  ## Start
12
14
 
@@ -16,9 +18,12 @@ You need Docker.
16
18
  docker compose up --build
17
19
  ```
18
20
 
19
- - Site: http://localhost:3000
20
- - Admin: http://localhost:3000/admin/ (user `admin`, password `ADMIN_PASSWORD` in `.env`)
21
- - Outgoing mail: http://localhost:8025
21
+ - Site: http://localhost:{{site_port}}
22
+ - Admin: http://localhost:{{site_port}}/admin/ (user `admin`, password `ADMIN_PASSWORD` in `.env`)
23
+ - Outgoing mail: http://localhost:{{mail_port}}
24
+
25
+ The ports are `SITE_PORT` and `MAIL_PORT` in `.env`. When you change
26
+ `SITE_PORT`, change `SITE_URL` and `BACKEND_URL` with it.
22
27
 
23
28
  The first start migrates the database, creates the admin user and adds demo
24
29
  content. Edit a page in the admin, publish, and reload the site.