@django-stack/cli 0.5.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.
- package/dist/bin.js +2161 -515
- package/dist/index.d.ts +54 -7
- package/dist/index.js +1 -1
- package/dist/{scaffold-DbgGYEsc.js → production-DBpWWx6i.js} +471 -103
- package/package.json +1 -1
- package/templates/backend/config/studio.py +9 -0
- package/templates/backend/content/admin.py +16 -2
- package/templates/backend/entrypoint.sh +5 -1
- package/templates/backend/pyproject.toml +2 -2
- package/templates/base/AGENTS.md +2 -1
- package/templates/base/Caddyfile +14 -1
- package/templates/base/README.md +18 -4
- package/templates/base/compose.yaml +57 -9
- package/templates/base/env.example +37 -4
- package/templates/frontend-astro/astro.config.mjs +13 -6
- package/templates/frontend-astro/package.json +1 -1
- package/templates/frontend-astro/src/layouts/Base.astro +3 -0
- package/templates/frontend-next/package.json +3 -3
- package/templates/frontend-next/src/app/layout.tsx +9 -0
- package/templates/frontend-next/src/proxy.ts +6 -0
- package/templates/frontend-nuxt/nuxt.config.ts +12 -6
- package/templates/frontend-nuxt/package.json +2 -2
- package/templates/frontend-nuxt/server/routes/_content/layout.get.ts +6 -1
- package/templates/frontend-nuxt/src/layouts/default.vue +3 -0
- package/templates/frontend-sveltekit/package.json +2 -2
- package/templates/frontend-sveltekit/src/routes/+layout.server.ts +2 -0
- package/templates/frontend-sveltekit/src/routes/+layout.svelte +3 -0
- package/templates/frontend-sveltekit/vite.config.ts +11 -3
|
@@ -2,8 +2,9 @@ import { createRequire } from "node:module";
|
|
|
2
2
|
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join, relative } from "node:path";
|
|
4
4
|
import { execFileSync } from "node:child_process";
|
|
5
|
-
import { randomBytes } from "node:crypto";
|
|
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) {
|
|
@@ -283,69 +450,9 @@ function generateProjectSecrets() {
|
|
|
283
450
|
api_read: generateSecret(32),
|
|
284
451
|
preview: generateSecret(32),
|
|
285
452
|
revalidate: generateSecret(32),
|
|
286
|
-
admin: generateSecret(12)
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
// src/production.ts
|
|
290
|
-
function validateDomain(domain) {
|
|
291
|
-
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://.";
|
|
292
|
-
}
|
|
293
|
-
/**
|
|
294
|
-
* The .env for a server, from a project's development .env: the prod
|
|
295
|
-
* profile, HTTPS on the domain, and fresh secrets, so nothing from the
|
|
296
|
-
* laptop works on the server.
|
|
297
|
-
*/
|
|
298
|
-
function productionEnv(devEnv, options) {
|
|
299
|
-
const domainError = validateDomain(options.domain);
|
|
300
|
-
if (domainError) throw new Error(domainError);
|
|
301
|
-
const current = parseEnv(devEnv);
|
|
302
|
-
const secrets = generateProjectSecrets();
|
|
303
|
-
const url = `https://${options.domain.toLowerCase()}`;
|
|
304
|
-
const todo = [];
|
|
305
|
-
const profiles = (current.COMPOSE_PROFILES ?? "dev,db").split(",").map((profile) => profile.trim()).filter((profile) => profile && profile !== "dev" && profile !== "s3");
|
|
306
|
-
profiles.unshift("prod");
|
|
307
|
-
const values = {
|
|
308
|
-
COMPOSE_PROFILES: [...new Set(profiles)].join(","),
|
|
309
|
-
SITE_URL: url,
|
|
310
|
-
BACKEND_URL: url,
|
|
311
|
-
DJANGO_DEBUG: "0",
|
|
312
|
-
DJANGO_SECRET_KEY: secrets.django ?? "",
|
|
313
|
-
API_SECRET_KEY: secrets.api ?? "",
|
|
314
|
-
API_READ_KEY: secrets.api_read ?? "",
|
|
315
|
-
PREVIEW_SECRET: secrets.preview ?? "",
|
|
316
|
-
REVALIDATE_SECRET: secrets.revalidate ?? "",
|
|
317
|
-
ADMIN_PASSWORD: secrets.admin ?? "",
|
|
318
|
-
DEFAULT_FROM_EMAIL: `${current.PROJECT_NAME ?? "site"} <noreply@${options.domain.toLowerCase()}>`
|
|
319
|
-
};
|
|
320
|
-
if (profiles.includes("db")) {
|
|
321
|
-
const user = current.POSTGRES_USER || "app";
|
|
322
|
-
const name = current.POSTGRES_DB || "app";
|
|
323
|
-
values.POSTGRES_PASSWORD = secrets.postgres ?? "";
|
|
324
|
-
values.DATABASE_URL = `postgres://${user}:${secrets.postgres}@db:5432/${name}`;
|
|
325
|
-
}
|
|
326
|
-
if (options.emailUrl) values.EMAIL_URL = options.emailUrl;
|
|
327
|
-
else if (!current.EMAIL_URL || current.EMAIL_URL.includes("mailpit")) {
|
|
328
|
-
values.EMAIL_URL = "smtp+tls://user:password@smtp.example.com:587";
|
|
329
|
-
todo.push("Set EMAIL_URL to your SMTP server, so password resets and notifications are sent.");
|
|
330
|
-
}
|
|
331
|
-
if ((current.S3_ENDPOINT_URL ?? "").includes("s3:8333")) {
|
|
332
|
-
Object.assign(values, {
|
|
333
|
-
S3_BUCKET: "",
|
|
334
|
-
S3_ENDPOINT_URL: "",
|
|
335
|
-
S3_REGION: "",
|
|
336
|
-
S3_ACCESS_KEY_ID: "",
|
|
337
|
-
S3_SECRET_ACCESS_KEY: "",
|
|
338
|
-
S3_PUBLIC_URL: "",
|
|
339
|
-
S3_CREATE_BUCKET: "0"
|
|
340
|
-
});
|
|
341
|
-
todo.push("Set the S3_* variables to your S3 service, or leave them empty to keep uploads on the server.");
|
|
342
|
-
}
|
|
343
|
-
if (!current.ADMIN_EMAIL || current.ADMIN_EMAIL.endsWith("@example.com")) todo.push("Set ADMIN_EMAIL to your address.");
|
|
344
|
-
if (current.SEED_DEMO_CONTENT === "1") todo.push("Set SEED_DEMO_CONTENT=0 unless the site should start with the demo pages and posts.");
|
|
345
|
-
return {
|
|
346
|
-
text: setEnvValues(devEnv, values),
|
|
347
|
-
adminPassword: values.ADMIN_PASSWORD ?? "",
|
|
348
|
-
todo
|
|
453
|
+
admin: generateSecret(12),
|
|
454
|
+
umami_app: generateSecret(32),
|
|
455
|
+
umami_admin: generateSecret(24)
|
|
349
456
|
};
|
|
350
457
|
}
|
|
351
458
|
// src/stack.ts
|
|
@@ -355,6 +462,7 @@ const STACK_OPTIONS = [
|
|
|
355
462
|
flag: "--frontend",
|
|
356
463
|
message: "Frontend",
|
|
357
464
|
description: "The frontend framework",
|
|
465
|
+
bits: 4,
|
|
358
466
|
values: [
|
|
359
467
|
{
|
|
360
468
|
value: "astro",
|
|
@@ -399,6 +507,7 @@ const STACK_OPTIONS = [
|
|
|
399
507
|
flag: "--postgres",
|
|
400
508
|
message: "Postgres version",
|
|
401
509
|
description: "The bundled Postgres version",
|
|
510
|
+
bits: 2,
|
|
402
511
|
values: [{
|
|
403
512
|
value: "stable",
|
|
404
513
|
label: "Stable",
|
|
@@ -416,11 +525,12 @@ const STACK_OPTIONS = [
|
|
|
416
525
|
flag: "--analytics",
|
|
417
526
|
message: "Analytics",
|
|
418
527
|
description: "Page analytics",
|
|
528
|
+
bits: 2,
|
|
419
529
|
values: [{
|
|
420
530
|
value: "umami",
|
|
421
531
|
label: "Umami",
|
|
422
532
|
hint: "built in, private, no cookies",
|
|
423
|
-
generated:
|
|
533
|
+
generated: true
|
|
424
534
|
}, {
|
|
425
535
|
value: "off",
|
|
426
536
|
label: "Off",
|
|
@@ -432,39 +542,52 @@ const STACK_OPTIONS = [
|
|
|
432
542
|
flag: "--tasks",
|
|
433
543
|
message: "Background tasks",
|
|
434
544
|
description: "The task queue",
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
+
]
|
|
445
566
|
},
|
|
446
567
|
{
|
|
447
568
|
key: "cache",
|
|
448
569
|
flag: "--cache",
|
|
449
570
|
message: "Cache",
|
|
450
571
|
description: "The cache backend",
|
|
572
|
+
bits: 3,
|
|
451
573
|
values: [
|
|
452
574
|
{
|
|
453
575
|
value: "database",
|
|
454
576
|
label: "Database",
|
|
455
577
|
hint: "a Postgres table, nothing extra to run",
|
|
456
|
-
generated:
|
|
578
|
+
generated: true
|
|
457
579
|
},
|
|
458
580
|
{
|
|
459
581
|
value: "valkey",
|
|
460
582
|
label: "Valkey",
|
|
461
583
|
hint: "in memory, Redis-compatible",
|
|
462
|
-
generated:
|
|
584
|
+
generated: true
|
|
463
585
|
},
|
|
464
586
|
{
|
|
465
587
|
value: "off",
|
|
466
588
|
label: "Off",
|
|
467
|
-
|
|
589
|
+
hint: "a cache per process",
|
|
590
|
+
generated: true
|
|
468
591
|
}
|
|
469
592
|
]
|
|
470
593
|
},
|
|
@@ -473,6 +596,7 @@ const STACK_OPTIONS = [
|
|
|
473
596
|
flag: "--notifications",
|
|
474
597
|
message: "Notifications",
|
|
475
598
|
description: "Notifications in the admin and by email",
|
|
599
|
+
bits: 2,
|
|
476
600
|
values: [{
|
|
477
601
|
value: "on",
|
|
478
602
|
label: "On",
|
|
@@ -489,6 +613,7 @@ const STACK_OPTIONS = [
|
|
|
489
613
|
flag: "--captcha",
|
|
490
614
|
message: "Captcha",
|
|
491
615
|
description: "The built-in captcha for forms",
|
|
616
|
+
bits: 2,
|
|
492
617
|
values: [{
|
|
493
618
|
value: "on",
|
|
494
619
|
label: "On",
|
|
@@ -505,6 +630,7 @@ const STACK_OPTIONS = [
|
|
|
505
630
|
flag: "--storage",
|
|
506
631
|
message: "Uploads in development",
|
|
507
632
|
description: "Where uploads go in development",
|
|
633
|
+
bits: 2,
|
|
508
634
|
values: [{
|
|
509
635
|
value: "local",
|
|
510
636
|
label: "Local files",
|
|
@@ -522,6 +648,7 @@ const STACK_OPTIONS = [
|
|
|
522
648
|
flag: "--package-manager",
|
|
523
649
|
message: "Package manager",
|
|
524
650
|
description: "The frontend's package manager",
|
|
651
|
+
bits: 3,
|
|
525
652
|
values: [
|
|
526
653
|
{
|
|
527
654
|
value: "pnpm",
|
|
@@ -539,14 +666,49 @@ const STACK_OPTIONS = [
|
|
|
539
666
|
generated: false
|
|
540
667
|
}
|
|
541
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
|
+
}]
|
|
542
687
|
}
|
|
543
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
|
+
})();
|
|
544
700
|
function stackOption(key) {
|
|
545
701
|
const option = STACK_OPTIONS.find((candidate) => candidate.key === key);
|
|
546
702
|
if (!option) throw new Error(`Unknown stack option ${key}`);
|
|
547
703
|
return option;
|
|
548
704
|
}
|
|
549
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
|
+
}
|
|
550
712
|
/** The frontends that run on Node, with a `frontend/` directory and a package manager. */
|
|
551
713
|
const NODE_FRONTENDS = [
|
|
552
714
|
"astro",
|
|
@@ -559,26 +721,68 @@ function applies(key, stack) {
|
|
|
559
721
|
if (key === "packageManager") return stack.frontend === void 0 || NODE_FRONTENDS.includes(stack.frontend);
|
|
560
722
|
return true;
|
|
561
723
|
}
|
|
562
|
-
/** The preset format version, the first character of a code. */
|
|
563
|
-
const VERSION = "a";
|
|
564
724
|
const DIGITS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
565
|
-
/**
|
|
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`. */
|
|
566
763
|
function encodePreset(stack) {
|
|
567
|
-
|
|
764
|
+
let number = 0n;
|
|
765
|
+
let shift = 0n;
|
|
766
|
+
for (const option of STACK_OPTIONS) {
|
|
568
767
|
const index = option.values.findIndex((value) => value.value === stack[option.key]);
|
|
569
768
|
if (index < 0) throw new Error(`${JSON.stringify(stack[option.key])} is not a ${option.key}.`);
|
|
570
|
-
|
|
571
|
-
|
|
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
|
+
}
|
|
572
778
|
return VERSION + digits;
|
|
573
779
|
}
|
|
574
780
|
const UPGRADE = "This preset needs a newer CLI. Run npx @django-stack/cli@latest init.";
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
throw new Error(`${JSON.stringify(code)} is not a preset.`);
|
|
581
|
-
}
|
|
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) {
|
|
582
786
|
const digits = code.slice(1);
|
|
583
787
|
if (digits.length > STACK_OPTIONS.length) throw new Error(UPGRADE);
|
|
584
788
|
const stack = { ...DEFAULT_STACK };
|
|
@@ -590,6 +794,31 @@ function decodePreset(code) {
|
|
|
590
794
|
});
|
|
591
795
|
return stack;
|
|
592
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
|
+
}
|
|
593
822
|
const NAMED_PRESETS = {
|
|
594
823
|
blog: {
|
|
595
824
|
description: "Astro with everything on (the defaults)",
|
|
@@ -642,16 +871,65 @@ const CLI_VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
|
642
871
|
// src/scaffold.ts
|
|
643
872
|
/** The frontends this version of the CLI generates. */
|
|
644
873
|
const FRONTENDS = stackOption("frontend").values.filter((value) => value.generated).map((value) => value.value);
|
|
645
|
-
/**
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
874
|
+
/** Umami's database URL: the project's database, with Umami's tables in the `umami` schema. */
|
|
875
|
+
function umamiDatabaseUrl(databaseUrl) {
|
|
876
|
+
const url = new URL(databaseUrl);
|
|
877
|
+
url.searchParams.set("schema", "umami");
|
|
878
|
+
return url.toString();
|
|
879
|
+
}
|
|
880
|
+
/** The `.env` values of the bundled Umami, from the project's database URL and secrets. */
|
|
881
|
+
function umamiEnv(databaseUrl, secrets) {
|
|
882
|
+
return {
|
|
883
|
+
UMAMI_URL: "http://umami:3000",
|
|
884
|
+
UMAMI_WEBSITE_ID: randomUUID(),
|
|
885
|
+
UMAMI_ADMIN_PASSWORD: secrets.umami_admin ?? "",
|
|
886
|
+
UMAMI_APP_SECRET: secrets.umami_app ?? "",
|
|
887
|
+
UMAMI_DATABASE_URL: umamiDatabaseUrl(databaseUrl)
|
|
888
|
+
};
|
|
889
|
+
}
|
|
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
|
+
}
|
|
654
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();
|
|
655
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$/;
|
|
656
934
|
function defaultTemplatesDir() {
|
|
657
935
|
return fileURLToPath(new URL("../templates", import.meta.url));
|
|
@@ -724,7 +1002,9 @@ function scaffold(options) {
|
|
|
724
1002
|
frontend_url: frontend.url,
|
|
725
1003
|
frontend_security: frontend.security,
|
|
726
1004
|
cli_version: CLI_VERSION,
|
|
727
|
-
preset: options.preset ??
|
|
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)
|
|
728
1008
|
};
|
|
729
1009
|
const files = walk(target);
|
|
730
1010
|
for (const file of files) {
|
|
@@ -749,7 +1029,18 @@ function scaffold(options) {
|
|
|
749
1029
|
profiles.push("s3");
|
|
750
1030
|
env = setEnvValues(env, LOCAL_S3);
|
|
751
1031
|
}
|
|
1032
|
+
if (options.analytics) {
|
|
1033
|
+
profiles.push("analytics");
|
|
1034
|
+
env = setEnvValues(env, umamiEnv(parseEnv(env).DATABASE_URL ?? "", secrets));
|
|
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");
|
|
752
1042
|
env = setEnvValues(env, { COMPOSE_PROFILES: profiles.join(",") });
|
|
1043
|
+
if (options.ports) env = setEnvValues(env, portValues(parseEnv(env), options.ports));
|
|
753
1044
|
if (!options.demoContent) env = setEnvValues(env, { SEED_DEMO_CONTENT: "0" });
|
|
754
1045
|
writeFileSync(join(target, ".env"), env, { mode: 384 });
|
|
755
1046
|
writeFileSync(join(target, ".env.example"), envExample);
|
|
@@ -830,4 +1121,81 @@ function relativeDirectory(directory) {
|
|
|
830
1121
|
const path = relative(process.cwd(), directory) || ".";
|
|
831
1122
|
return path.startsWith("..") ? directory : path;
|
|
832
1123
|
}
|
|
833
|
-
|
|
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
|
+
}
|
|
1134
|
+
function validateDomain(domain) {
|
|
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://.";
|
|
1136
|
+
}
|
|
1137
|
+
/**
|
|
1138
|
+
* The .env for a server, from a project's development .env: the prod
|
|
1139
|
+
* profile, HTTPS on the domain, and fresh secrets, so nothing from the
|
|
1140
|
+
* laptop works on the server.
|
|
1141
|
+
*/
|
|
1142
|
+
function productionEnv(devEnv, options) {
|
|
1143
|
+
const domainError = validateDomain(options.domain);
|
|
1144
|
+
if (domainError) throw new Error(domainError);
|
|
1145
|
+
const current = parseEnv(devEnv);
|
|
1146
|
+
const secrets = generateProjectSecrets();
|
|
1147
|
+
const url = `https://${options.domain.toLowerCase()}`;
|
|
1148
|
+
const todo = [];
|
|
1149
|
+
const profiles = (current.COMPOSE_PROFILES ?? "dev,db").split(",").map((profile) => profile.trim()).filter((profile) => profile && profile !== "dev" && profile !== "s3");
|
|
1150
|
+
profiles.unshift("prod");
|
|
1151
|
+
const values = {
|
|
1152
|
+
COMPOSE_PROFILES: [...new Set(profiles)].join(","),
|
|
1153
|
+
SITE_URL: url,
|
|
1154
|
+
BACKEND_URL: url,
|
|
1155
|
+
DJANGO_DEBUG: "0",
|
|
1156
|
+
DJANGO_SECRET_KEY: secrets.django ?? "",
|
|
1157
|
+
API_SECRET_KEY: secrets.api ?? "",
|
|
1158
|
+
API_READ_KEY: secrets.api_read ?? "",
|
|
1159
|
+
PREVIEW_SECRET: secrets.preview ?? "",
|
|
1160
|
+
REVALIDATE_SECRET: secrets.revalidate ?? "",
|
|
1161
|
+
ADMIN_PASSWORD: secrets.admin ?? "",
|
|
1162
|
+
DEFAULT_FROM_EMAIL: `${current.PROJECT_NAME ?? "site"} <noreply@${options.domain.toLowerCase()}>`
|
|
1163
|
+
};
|
|
1164
|
+
if (profiles.includes("db")) {
|
|
1165
|
+
const user = current.POSTGRES_USER || "app";
|
|
1166
|
+
const name = current.POSTGRES_DB || "app";
|
|
1167
|
+
values.POSTGRES_PASSWORD = secrets.postgres ?? "";
|
|
1168
|
+
values.DATABASE_URL = `postgres://${user}:${secrets.postgres}@db:5432/${name}`;
|
|
1169
|
+
}
|
|
1170
|
+
if (current.UMAMI_URL) {
|
|
1171
|
+
values.UMAMI_ADMIN_PASSWORD = secrets.umami_admin ?? "";
|
|
1172
|
+
values.UMAMI_APP_SECRET = secrets.umami_app ?? "";
|
|
1173
|
+
values.UMAMI_DATABASE_URL = umamiDatabaseUrl(values.DATABASE_URL ?? current.DATABASE_URL ?? "");
|
|
1174
|
+
}
|
|
1175
|
+
if (options.emailUrl) values.EMAIL_URL = options.emailUrl;
|
|
1176
|
+
else if (!current.EMAIL_URL || current.EMAIL_URL.includes("mailpit")) {
|
|
1177
|
+
values.EMAIL_URL = "smtp+tls://user:password@smtp.example.com:587";
|
|
1178
|
+
todo.push("Set EMAIL_URL to your SMTP server, so password resets and notifications are sent.");
|
|
1179
|
+
}
|
|
1180
|
+
if ((current.S3_ENDPOINT_URL ?? "").includes("s3:8333")) {
|
|
1181
|
+
Object.assign(values, {
|
|
1182
|
+
S3_BUCKET: "",
|
|
1183
|
+
S3_ENDPOINT_URL: "",
|
|
1184
|
+
S3_REGION: "",
|
|
1185
|
+
S3_ACCESS_KEY_ID: "",
|
|
1186
|
+
S3_SECRET_ACCESS_KEY: "",
|
|
1187
|
+
S3_PUBLIC_URL: "",
|
|
1188
|
+
S3_CREATE_BUCKET: "0"
|
|
1189
|
+
});
|
|
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.");
|
|
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.");
|
|
1193
|
+
if (!current.ADMIN_EMAIL || current.ADMIN_EMAIL.endsWith("@example.com")) todo.push("Set ADMIN_EMAIL to your address.");
|
|
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.");
|
|
1195
|
+
return {
|
|
1196
|
+
text: setEnvValues(devEnv, values),
|
|
1197
|
+
adminPassword: values.ADMIN_PASSWORD ?? "",
|
|
1198
|
+
todo
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
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 };
|