@django-stack/cli 0.6.0 → 0.8.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/README.md +1 -0
- package/dist/bin.js +660 -347
- package/dist/index.d.ts +51 -6
- package/dist/index.js +1 -1
- package/dist/{production-DIj9yRcg.js → production-CGkI7oNe.js} +641 -57
- package/package.json +1 -1
- package/templates/backend/Dockerfile +4 -2
- package/templates/backend/pyproject.toml +1 -1
- package/templates/base/README.md +9 -4
- package/templates/base/compose.yaml +19 -9
- package/templates/base/env.example +23 -4
- package/templates/frontend-astro/package.json +1 -1
- package/templates/frontend-next/package.json +3 -3
- package/templates/frontend-nuxt/package.json +2 -2
- package/templates/frontend-sveltekit/package.json +2 -2
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
-
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { join, relative } from "node:path";
|
|
4
|
-
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
4
|
+
import { execFileSync, spawnSync } 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,25 +298,144 @@ async function runDoctor(directory) {
|
|
|
269
298
|
});
|
|
270
299
|
return checks;
|
|
271
300
|
}
|
|
272
|
-
// src/
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
+
});
|
|
276
342
|
}
|
|
277
|
-
/**
|
|
278
|
-
function
|
|
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
|
+
}
|
|
279
429
|
return {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
api_read: generateSecret(32),
|
|
284
|
-
preview: generateSecret(32),
|
|
285
|
-
revalidate: generateSecret(32),
|
|
286
|
-
admin: generateSecret(12),
|
|
287
|
-
umami_app: generateSecret(32),
|
|
288
|
-
umami_admin: generateSecret(24)
|
|
430
|
+
ports,
|
|
431
|
+
moves,
|
|
432
|
+
stuck
|
|
289
433
|
};
|
|
290
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
|
+
}
|
|
291
439
|
// src/stack.ts
|
|
292
440
|
const STACK_OPTIONS = [
|
|
293
441
|
{
|
|
@@ -295,6 +443,7 @@ const STACK_OPTIONS = [
|
|
|
295
443
|
flag: "--frontend",
|
|
296
444
|
message: "Frontend",
|
|
297
445
|
description: "The frontend framework",
|
|
446
|
+
bits: 4,
|
|
298
447
|
values: [
|
|
299
448
|
{
|
|
300
449
|
value: "astro",
|
|
@@ -339,6 +488,7 @@ const STACK_OPTIONS = [
|
|
|
339
488
|
flag: "--postgres",
|
|
340
489
|
message: "Postgres version",
|
|
341
490
|
description: "The bundled Postgres version",
|
|
491
|
+
bits: 2,
|
|
342
492
|
values: [{
|
|
343
493
|
value: "stable",
|
|
344
494
|
label: "Stable",
|
|
@@ -356,6 +506,7 @@ const STACK_OPTIONS = [
|
|
|
356
506
|
flag: "--analytics",
|
|
357
507
|
message: "Analytics",
|
|
358
508
|
description: "Page analytics",
|
|
509
|
+
bits: 2,
|
|
359
510
|
values: [{
|
|
360
511
|
value: "umami",
|
|
361
512
|
label: "Umami",
|
|
@@ -372,39 +523,52 @@ const STACK_OPTIONS = [
|
|
|
372
523
|
flag: "--tasks",
|
|
373
524
|
message: "Background tasks",
|
|
374
525
|
description: "The task queue",
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
526
|
+
bits: 2,
|
|
527
|
+
values: [
|
|
528
|
+
{
|
|
529
|
+
value: "huey",
|
|
530
|
+
label: "Huey",
|
|
531
|
+
hint: "a worker container, the queue where the cache is",
|
|
532
|
+
generated: true
|
|
533
|
+
},
|
|
534
|
+
{
|
|
535
|
+
value: "off",
|
|
536
|
+
label: "Off",
|
|
537
|
+
hint: "tasks run in the request, no daily backups",
|
|
538
|
+
generated: true
|
|
539
|
+
},
|
|
540
|
+
{
|
|
541
|
+
value: "huey-valkey",
|
|
542
|
+
label: "Huey on Valkey",
|
|
543
|
+
hint: "the queue in Valkey",
|
|
544
|
+
generated: true
|
|
545
|
+
}
|
|
546
|
+
]
|
|
385
547
|
},
|
|
386
548
|
{
|
|
387
549
|
key: "cache",
|
|
388
550
|
flag: "--cache",
|
|
389
551
|
message: "Cache",
|
|
390
552
|
description: "The cache backend",
|
|
553
|
+
bits: 3,
|
|
391
554
|
values: [
|
|
392
555
|
{
|
|
393
556
|
value: "database",
|
|
394
557
|
label: "Database",
|
|
395
558
|
hint: "a Postgres table, nothing extra to run",
|
|
396
|
-
generated:
|
|
559
|
+
generated: true
|
|
397
560
|
},
|
|
398
561
|
{
|
|
399
562
|
value: "valkey",
|
|
400
563
|
label: "Valkey",
|
|
401
564
|
hint: "in memory, Redis-compatible",
|
|
402
|
-
generated:
|
|
565
|
+
generated: true
|
|
403
566
|
},
|
|
404
567
|
{
|
|
405
568
|
value: "off",
|
|
406
569
|
label: "Off",
|
|
407
|
-
|
|
570
|
+
hint: "a cache per process",
|
|
571
|
+
generated: true
|
|
408
572
|
}
|
|
409
573
|
]
|
|
410
574
|
},
|
|
@@ -413,6 +577,7 @@ const STACK_OPTIONS = [
|
|
|
413
577
|
flag: "--notifications",
|
|
414
578
|
message: "Notifications",
|
|
415
579
|
description: "Notifications in the admin and by email",
|
|
580
|
+
bits: 2,
|
|
416
581
|
values: [{
|
|
417
582
|
value: "on",
|
|
418
583
|
label: "On",
|
|
@@ -429,6 +594,7 @@ const STACK_OPTIONS = [
|
|
|
429
594
|
flag: "--captcha",
|
|
430
595
|
message: "Captcha",
|
|
431
596
|
description: "The built-in captcha for forms",
|
|
597
|
+
bits: 2,
|
|
432
598
|
values: [{
|
|
433
599
|
value: "on",
|
|
434
600
|
label: "On",
|
|
@@ -445,6 +611,7 @@ const STACK_OPTIONS = [
|
|
|
445
611
|
flag: "--storage",
|
|
446
612
|
message: "Uploads in development",
|
|
447
613
|
description: "Where uploads go in development",
|
|
614
|
+
bits: 2,
|
|
448
615
|
values: [{
|
|
449
616
|
value: "local",
|
|
450
617
|
label: "Local files",
|
|
@@ -462,6 +629,7 @@ const STACK_OPTIONS = [
|
|
|
462
629
|
flag: "--package-manager",
|
|
463
630
|
message: "Package manager",
|
|
464
631
|
description: "The frontend's package manager",
|
|
632
|
+
bits: 3,
|
|
465
633
|
values: [
|
|
466
634
|
{
|
|
467
635
|
value: "pnpm",
|
|
@@ -479,14 +647,49 @@ const STACK_OPTIONS = [
|
|
|
479
647
|
generated: false
|
|
480
648
|
}
|
|
481
649
|
]
|
|
650
|
+
},
|
|
651
|
+
{
|
|
652
|
+
key: "media",
|
|
653
|
+
flag: "--media",
|
|
654
|
+
message: "Uploads in production",
|
|
655
|
+
description: "Where uploads go in production",
|
|
656
|
+
bits: 2,
|
|
657
|
+
values: [{
|
|
658
|
+
value: "server",
|
|
659
|
+
label: "On the server",
|
|
660
|
+
hint: "a Docker volume, backed up with the database",
|
|
661
|
+
generated: true
|
|
662
|
+
}, {
|
|
663
|
+
value: "s3",
|
|
664
|
+
label: "S3",
|
|
665
|
+
hint: "any S3-compatible service; credentials later",
|
|
666
|
+
generated: true
|
|
667
|
+
}]
|
|
482
668
|
}
|
|
483
669
|
];
|
|
670
|
+
/**
|
|
671
|
+
* The order of the prompts. It differs from STACK_OPTIONS (the preset order,
|
|
672
|
+
* append-only): the cache comes before the tasks, whose queue follows it,
|
|
673
|
+
* and uploads in production before uploads in development.
|
|
674
|
+
*/
|
|
675
|
+
const PROMPT_ORDER = (() => {
|
|
676
|
+
const keys = STACK_OPTIONS.map((option) => option.key).filter((key) => key !== "media" && key !== "cache");
|
|
677
|
+
keys.splice(keys.indexOf("storage"), 0, "media");
|
|
678
|
+
keys.splice(keys.indexOf("tasks"), 0, "cache");
|
|
679
|
+
return keys;
|
|
680
|
+
})();
|
|
484
681
|
function stackOption(key) {
|
|
485
682
|
const option = STACK_OPTIONS.find((candidate) => candidate.key === key);
|
|
486
683
|
if (!option) throw new Error(`Unknown stack option ${key}`);
|
|
487
684
|
return option;
|
|
488
685
|
}
|
|
489
686
|
const DEFAULT_STACK = Object.fromEntries(STACK_OPTIONS.map((option) => [option.key, option.values[0].value]));
|
|
687
|
+
/** Where the task queue of a stack is: the database, Valkey, or none (tasks run in the request). */
|
|
688
|
+
function taskQueue(stack) {
|
|
689
|
+
if (stack.tasks === "off") return "none";
|
|
690
|
+
if (stack.tasks === "huey-valkey" || stack.cache === "valkey") return "valkey";
|
|
691
|
+
return "database";
|
|
692
|
+
}
|
|
490
693
|
/** The frontends that run on Node, with a `frontend/` directory and a package manager. */
|
|
491
694
|
const NODE_FRONTENDS = [
|
|
492
695
|
"astro",
|
|
@@ -499,26 +702,68 @@ function applies(key, stack) {
|
|
|
499
702
|
if (key === "packageManager") return stack.frontend === void 0 || NODE_FRONTENDS.includes(stack.frontend);
|
|
500
703
|
return true;
|
|
501
704
|
}
|
|
502
|
-
/** The preset format version, the first character of a code. */
|
|
503
|
-
const VERSION = "a";
|
|
504
705
|
const DIGITS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
505
|
-
/**
|
|
706
|
+
/**
|
|
707
|
+
* Preset codes are six characters: the format version, then the stack as
|
|
708
|
+
* one number in five base62 digits. The options are bit fields of that
|
|
709
|
+
* number, the first option in the lowest bits; the bits above the last
|
|
710
|
+
* option are for options to come and must be 0.
|
|
711
|
+
*
|
|
712
|
+
* The number is multiplied by MULTIPLIER and offset by OFFSET (modulo
|
|
713
|
+
* 62^5) before it is written, so codes look different from each other
|
|
714
|
+
* rather than mostly zeros, and a mistyped code is usually refused instead
|
|
715
|
+
* of meaning another stack. Both constants are part of the format: never
|
|
716
|
+
* change them.
|
|
717
|
+
*/
|
|
718
|
+
const VERSION = "b";
|
|
719
|
+
/** The first format: one character per option, trailing defaults left out. */
|
|
720
|
+
const VERSION_A = "a";
|
|
721
|
+
const LENGTH = 5;
|
|
722
|
+
const MODULUS = 62n ** BigInt(LENGTH);
|
|
723
|
+
/** The bits a code can hold: 2^29 is below 62^5. */
|
|
724
|
+
const CAPACITY = 29;
|
|
725
|
+
const MULTIPLIER = 387420489n;
|
|
726
|
+
const OFFSET = 578291047n;
|
|
727
|
+
/** The inverse of `value` modulo `modulus`. */
|
|
728
|
+
function inverse(value, modulus) {
|
|
729
|
+
let [a, b, x, y] = [
|
|
730
|
+
value % modulus,
|
|
731
|
+
modulus,
|
|
732
|
+
1n,
|
|
733
|
+
0n
|
|
734
|
+
];
|
|
735
|
+
while (b) {
|
|
736
|
+
const q = a / b;
|
|
737
|
+
[a, b] = [b, a - q * b];
|
|
738
|
+
[x, y] = [y, x - q * y];
|
|
739
|
+
}
|
|
740
|
+
return (x % modulus + modulus) % modulus;
|
|
741
|
+
}
|
|
742
|
+
const MULTIPLIER_INVERSE = inverse(MULTIPLIER, MODULUS);
|
|
743
|
+
/** A preset code for a stack: always six characters, such as `b8Kq2x`. */
|
|
506
744
|
function encodePreset(stack) {
|
|
507
|
-
|
|
745
|
+
let number = 0n;
|
|
746
|
+
let shift = 0n;
|
|
747
|
+
for (const option of STACK_OPTIONS) {
|
|
508
748
|
const index = option.values.findIndex((value) => value.value === stack[option.key]);
|
|
509
749
|
if (index < 0) throw new Error(`${JSON.stringify(stack[option.key])} is not a ${option.key}.`);
|
|
510
|
-
|
|
511
|
-
|
|
750
|
+
number |= BigInt(index) << shift;
|
|
751
|
+
shift += BigInt(option.bits);
|
|
752
|
+
}
|
|
753
|
+
let scrambled = (number * MULTIPLIER + OFFSET) % MODULUS;
|
|
754
|
+
let digits = "";
|
|
755
|
+
for (let position = 0; position < LENGTH; position++) {
|
|
756
|
+
digits = DIGITS[Number(scrambled % 62n)] + digits;
|
|
757
|
+
scrambled /= 62n;
|
|
758
|
+
}
|
|
512
759
|
return VERSION + digits;
|
|
513
760
|
}
|
|
514
761
|
const UPGRADE = "This preset needs a newer CLI. Run npx @django-stack/cli@latest init.";
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
throw new Error(`${JSON.stringify(code)} is not a preset.`);
|
|
521
|
-
}
|
|
762
|
+
function notAPreset(code) {
|
|
763
|
+
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.`);
|
|
764
|
+
}
|
|
765
|
+
/** The stack of a code in the first format, which `init` wrote before 0.7. */
|
|
766
|
+
function decodeVersionA(code) {
|
|
522
767
|
const digits = code.slice(1);
|
|
523
768
|
if (digits.length > STACK_OPTIONS.length) throw new Error(UPGRADE);
|
|
524
769
|
const stack = { ...DEFAULT_STACK };
|
|
@@ -530,6 +775,31 @@ function decodePreset(code) {
|
|
|
530
775
|
});
|
|
531
776
|
return stack;
|
|
532
777
|
}
|
|
778
|
+
/** The stack of a preset code. Throws with a one-line reason for a bad code. */
|
|
779
|
+
function decodePreset(code) {
|
|
780
|
+
if (!/^[0-9a-zA-Z]+$/.test(code)) throw new Error(`${JSON.stringify(code)} is not a preset.`);
|
|
781
|
+
if (code[0] === VERSION_A) return decodeVersionA(code);
|
|
782
|
+
if (code[0] !== VERSION) {
|
|
783
|
+
if (DIGITS.indexOf(code[0] ?? "") > DIGITS.indexOf(VERSION)) throw new Error(UPGRADE);
|
|
784
|
+
throw new Error(`${JSON.stringify(code)} is not a preset.`);
|
|
785
|
+
}
|
|
786
|
+
if (code.length !== 6) throw notAPreset(code);
|
|
787
|
+
let scrambled = 0n;
|
|
788
|
+
for (const digit of code.slice(1)) scrambled = scrambled * 62n + BigInt(DIGITS.indexOf(digit));
|
|
789
|
+
let number = ((scrambled - OFFSET) % MODULUS + MODULUS) % MODULUS;
|
|
790
|
+
number = number * MULTIPLIER_INVERSE % MODULUS;
|
|
791
|
+
if (number >> BigInt(CAPACITY)) throw notAPreset(code);
|
|
792
|
+
const stack = {};
|
|
793
|
+
for (const option of STACK_OPTIONS) {
|
|
794
|
+
const index = Number(number & (1n << BigInt(option.bits)) - 1n);
|
|
795
|
+
number >>= BigInt(option.bits);
|
|
796
|
+
const value = option.values[index];
|
|
797
|
+
if (!value) throw notAPreset(code);
|
|
798
|
+
stack[option.key] = value.value;
|
|
799
|
+
}
|
|
800
|
+
if (number) throw notAPreset(code);
|
|
801
|
+
return stack;
|
|
802
|
+
}
|
|
533
803
|
const NAMED_PRESETS = {
|
|
534
804
|
blog: {
|
|
535
805
|
description: "Astro with everything on (the defaults)",
|
|
@@ -576,9 +846,270 @@ function pendingChoices(stack) {
|
|
|
576
846
|
}];
|
|
577
847
|
});
|
|
578
848
|
}
|
|
849
|
+
// src/project.ts
|
|
850
|
+
/** The `.env` of `dstack run` on a development machine: the prod profile at https://site.localhost. */
|
|
851
|
+
const RUN_ENV = ".env.run";
|
|
852
|
+
const RUN_DOMAIN = "site.localhost";
|
|
853
|
+
/**
|
|
854
|
+
* The project's scripts that must be executable. npm and pnpm packages do
|
|
855
|
+
* not keep the executable bit of non-bin files, so the templates arrive
|
|
856
|
+
* without it.
|
|
857
|
+
*/
|
|
858
|
+
const EXECUTABLES = ["backend/entrypoint.sh", "backend/manage.py"];
|
|
859
|
+
/** Makes the project's scripts executable for everyone who can read them. */
|
|
860
|
+
function makeExecutable(directory) {
|
|
861
|
+
for (const file of EXECUTABLES) {
|
|
862
|
+
const path = join(directory, file);
|
|
863
|
+
try {
|
|
864
|
+
const { mode } = statSync(path);
|
|
865
|
+
const executable = mode | (mode & 292) >> 2;
|
|
866
|
+
if (executable !== mode) chmodSync(path, executable & 4095);
|
|
867
|
+
} catch {}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
/** The project directory: `start` or the nearest parent with `compose.yaml` and `.env`. */
|
|
871
|
+
function findProjectDirectory(start) {
|
|
872
|
+
let directory = resolve(start);
|
|
873
|
+
for (;;) {
|
|
874
|
+
if (existsSync(join(directory, "compose.yaml")) && existsSync(join(directory, ".env"))) return directory;
|
|
875
|
+
const parent = dirname(directory);
|
|
876
|
+
if (parent === directory) return void 0;
|
|
877
|
+
directory = parent;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
function parseProfiles(value) {
|
|
881
|
+
return (value ?? "").split(",").map((profile) => profile.trim()).filter(Boolean);
|
|
882
|
+
}
|
|
883
|
+
function projectName(directory) {
|
|
884
|
+
try {
|
|
885
|
+
const match = readFileSync(join(directory, "compose.yaml"), "utf8").match(/^name:\s*["']?([\w.-]+)/m);
|
|
886
|
+
if (match?.[1]) return match[1];
|
|
887
|
+
} catch {}
|
|
888
|
+
return basename(directory).toLowerCase();
|
|
889
|
+
}
|
|
890
|
+
function fromEnv(directory, name, envFile, localRun) {
|
|
891
|
+
const env = parseEnv(readFileSync(join(directory, envFile), "utf8"));
|
|
892
|
+
const profiles = parseProfiles(env.COMPOSE_PROFILES);
|
|
893
|
+
const prod = profiles.includes("prod");
|
|
894
|
+
const suffix = prod ? "-prod" : "";
|
|
895
|
+
return {
|
|
896
|
+
directory,
|
|
897
|
+
name: localRun ? `${name}-run` : name,
|
|
898
|
+
env,
|
|
899
|
+
profiles,
|
|
900
|
+
prod,
|
|
901
|
+
localRun,
|
|
902
|
+
services: {
|
|
903
|
+
backend: `backend${suffix}`,
|
|
904
|
+
frontend: `frontend${suffix}`,
|
|
905
|
+
worker: `worker${suffix}`
|
|
906
|
+
},
|
|
907
|
+
composeArgs: localRun ? [
|
|
908
|
+
"--env-file",
|
|
909
|
+
envFile,
|
|
910
|
+
"--project-name",
|
|
911
|
+
`${name}-run`
|
|
912
|
+
] : [],
|
|
913
|
+
composeEnv: localRun ? {
|
|
914
|
+
ENV_FILE: envFile,
|
|
915
|
+
COMPOSE_PROFILES: env.COMPOSE_PROFILES ?? ""
|
|
916
|
+
} : {}
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
/** Reads the project in or above `start`. Throws with a one-line reason when there is none. */
|
|
920
|
+
function readProject(start = ".") {
|
|
921
|
+
const directory = findProjectDirectory(start);
|
|
922
|
+
if (!directory) throw new Error(`No DjangoStack project in ${resolve(start)}: run this where compose.yaml and .env are.`);
|
|
923
|
+
makeExecutable(directory);
|
|
924
|
+
return fromEnv(directory, projectName(directory), ".env", false);
|
|
925
|
+
}
|
|
926
|
+
/** The local production run of a development project, when `.env.run` exists. */
|
|
927
|
+
function readLocalRun(project) {
|
|
928
|
+
if (project.prod || project.localRun || !existsSync(join(project.directory, ".env.run"))) return void 0;
|
|
929
|
+
return fromEnv(project.directory, projectName(project.directory), RUN_ENV, true);
|
|
930
|
+
}
|
|
931
|
+
/** The `django-stack` section of the project's `package.json`. */
|
|
932
|
+
function projectManifest(project) {
|
|
933
|
+
try {
|
|
934
|
+
return JSON.parse(readFileSync(join(project.directory, "package.json"), "utf8"))["django-stack"] ?? {};
|
|
935
|
+
} catch {
|
|
936
|
+
return {};
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* The project's stack: from the preset `init` saved in `package.json`, or,
|
|
941
|
+
* for projects from before that, the defaults with the storage from `.env`.
|
|
942
|
+
* Analytics follow the `analytics` profile: projects from before Umami was
|
|
943
|
+
* generated have the default in their preset without running it.
|
|
944
|
+
*/
|
|
945
|
+
function projectStack(project) {
|
|
946
|
+
const fromEnv = {
|
|
947
|
+
analytics: project.profiles.includes("analytics") ? "umami" : "off",
|
|
948
|
+
...project.env.CACHE_URL === void 0 ? {} : cacheStack(project.env)
|
|
949
|
+
};
|
|
950
|
+
const { preset } = projectManifest(project);
|
|
951
|
+
if (preset) try {
|
|
952
|
+
return {
|
|
953
|
+
...decodePreset(preset),
|
|
954
|
+
...fromEnv
|
|
955
|
+
};
|
|
956
|
+
} catch {}
|
|
957
|
+
return {
|
|
958
|
+
...DEFAULT_STACK,
|
|
959
|
+
...fromEnv,
|
|
960
|
+
storage: project.profiles.includes("s3") ? "s3" : "local"
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
const REDIS = /^(redis|rediss|valkey|valkeys):/;
|
|
964
|
+
/**
|
|
965
|
+
* The cache and the tasks a project's `.env` runs. Projects from before 0.7
|
|
966
|
+
* have no TASKS_URL: their tasks follow a Redis cache, like the backend does.
|
|
967
|
+
*/
|
|
968
|
+
function cacheStack(env) {
|
|
969
|
+
const cacheUrl = env.CACHE_URL ?? "";
|
|
970
|
+
const cache = cacheUrl.startsWith("db:") ? "database" : REDIS.test(cacheUrl) ? "valkey" : "off";
|
|
971
|
+
const tasksUrl = env.TASKS_URL || (cache === "valkey" ? cacheUrl : "immediate://");
|
|
972
|
+
if (tasksUrl.startsWith("immediate:")) return {
|
|
973
|
+
cache,
|
|
974
|
+
tasks: "off"
|
|
975
|
+
};
|
|
976
|
+
return {
|
|
977
|
+
cache,
|
|
978
|
+
tasks: REDIS.test(tasksUrl) && cache !== "valkey" ? "huey-valkey" : "huey"
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
/** The URLs of a running project. */
|
|
982
|
+
function projectUrls(project) {
|
|
983
|
+
if (project.prod) {
|
|
984
|
+
const site = (project.env.SITE_URL ?? "").replace(/\/$/, "");
|
|
985
|
+
return [{
|
|
986
|
+
label: "Site",
|
|
987
|
+
url: `${site}/`
|
|
988
|
+
}, {
|
|
989
|
+
label: "Admin",
|
|
990
|
+
url: `${site}/admin/`
|
|
991
|
+
}];
|
|
992
|
+
}
|
|
993
|
+
const ports = envPorts(project.env);
|
|
994
|
+
const site = (project.env.SITE_URL || `http://localhost:${ports.SITE_PORT}`).replace(/\/$/, "");
|
|
995
|
+
const urls = [
|
|
996
|
+
{
|
|
997
|
+
label: "Site",
|
|
998
|
+
url: site
|
|
999
|
+
},
|
|
1000
|
+
{
|
|
1001
|
+
label: "Admin",
|
|
1002
|
+
url: `${site}/admin/`
|
|
1003
|
+
},
|
|
1004
|
+
{
|
|
1005
|
+
label: "Mail",
|
|
1006
|
+
url: `http://localhost:${ports.MAIL_PORT}`
|
|
1007
|
+
}
|
|
1008
|
+
];
|
|
1009
|
+
if (project.profiles.includes("s3")) urls.push({
|
|
1010
|
+
label: "S3",
|
|
1011
|
+
url: `http://localhost:${ports.S3_PORT}`
|
|
1012
|
+
});
|
|
1013
|
+
return urls;
|
|
1014
|
+
}
|
|
1015
|
+
/** Runs a command with the terminal attached and returns its exit status. */
|
|
1016
|
+
const runInherit = (cmd, args, options) => {
|
|
1017
|
+
const result = spawnSync(cmd, args, {
|
|
1018
|
+
stdio: "inherit",
|
|
1019
|
+
...options
|
|
1020
|
+
});
|
|
1021
|
+
if (result.error) return 127;
|
|
1022
|
+
return result.status ?? 1;
|
|
1023
|
+
};
|
|
1024
|
+
/** The full `docker compose` argument list for a project. */
|
|
1025
|
+
function composeCommand(project, args) {
|
|
1026
|
+
return [
|
|
1027
|
+
"compose",
|
|
1028
|
+
...project.composeArgs,
|
|
1029
|
+
...args
|
|
1030
|
+
];
|
|
1031
|
+
}
|
|
1032
|
+
function composeOptions(project) {
|
|
1033
|
+
return {
|
|
1034
|
+
cwd: project.directory,
|
|
1035
|
+
env: {
|
|
1036
|
+
...process.env,
|
|
1037
|
+
...project.composeEnv
|
|
1038
|
+
}
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
/** Runs `docker compose <args>` for the project with the terminal attached. */
|
|
1042
|
+
function compose(project, args, run = runInherit) {
|
|
1043
|
+
return run("docker", composeCommand(project, args), composeOptions(project));
|
|
1044
|
+
}
|
|
1045
|
+
/** Runs `docker compose <args>` and captures the output. */
|
|
1046
|
+
function composeOutput(project, args) {
|
|
1047
|
+
const result = spawnSync("docker", composeCommand(project, args), {
|
|
1048
|
+
...composeOptions(project),
|
|
1049
|
+
encoding: "utf8",
|
|
1050
|
+
stdio: [
|
|
1051
|
+
"ignore",
|
|
1052
|
+
"pipe",
|
|
1053
|
+
"pipe"
|
|
1054
|
+
]
|
|
1055
|
+
});
|
|
1056
|
+
return {
|
|
1057
|
+
status: result.status ?? 1,
|
|
1058
|
+
output: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim()
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
/** The running services of the project. */
|
|
1062
|
+
function runningServices(project) {
|
|
1063
|
+
const result = spawnSync("docker", composeCommand(project, [
|
|
1064
|
+
"ps",
|
|
1065
|
+
"--services",
|
|
1066
|
+
"--filter",
|
|
1067
|
+
"status=running"
|
|
1068
|
+
]), {
|
|
1069
|
+
...composeOptions(project),
|
|
1070
|
+
encoding: "utf8",
|
|
1071
|
+
stdio: [
|
|
1072
|
+
"ignore",
|
|
1073
|
+
"pipe",
|
|
1074
|
+
"ignore"
|
|
1075
|
+
]
|
|
1076
|
+
});
|
|
1077
|
+
return result.status === 0 ? result.stdout.split("\n").filter(Boolean) : [];
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* The project a command acts on: the local production run when it is the
|
|
1081
|
+
* one running, otherwise the project `.env` describes.
|
|
1082
|
+
*/
|
|
1083
|
+
function activeProject(project) {
|
|
1084
|
+
const localRun = readLocalRun(project);
|
|
1085
|
+
if (!localRun) return project;
|
|
1086
|
+
if (runningServices(project).length) return project;
|
|
1087
|
+
return runningServices(localRun).length ? localRun : project;
|
|
1088
|
+
}
|
|
1089
|
+
// src/secrets.ts
|
|
1090
|
+
/** A URL-safe random secret with `bytes` bytes of randomness. */
|
|
1091
|
+
function generateSecret(bytes = 32) {
|
|
1092
|
+
return randomBytes(bytes).toString("base64url");
|
|
1093
|
+
}
|
|
1094
|
+
/** The secrets a project's .env needs, by the name used in `{{secret:<name>}}`. */
|
|
1095
|
+
function generateProjectSecrets() {
|
|
1096
|
+
return {
|
|
1097
|
+
django: generateSecret(48),
|
|
1098
|
+
postgres: generateSecret(24),
|
|
1099
|
+
api: generateSecret(32),
|
|
1100
|
+
api_read: generateSecret(32),
|
|
1101
|
+
preview: generateSecret(32),
|
|
1102
|
+
revalidate: generateSecret(32),
|
|
1103
|
+
admin: generateSecret(12),
|
|
1104
|
+
umami_app: generateSecret(32),
|
|
1105
|
+
umami_admin: generateSecret(24)
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
579
1108
|
// src/version.ts
|
|
580
1109
|
/** The version of this package, the same for every @django-stack package. */
|
|
581
1110
|
const CLI_VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
1111
|
+
/** The npm package of the CLI. */
|
|
1112
|
+
const PACKAGE_NAME = "@django-stack/cli";
|
|
582
1113
|
// src/scaffold.ts
|
|
583
1114
|
/** The frontends this version of the CLI generates. */
|
|
584
1115
|
const FRONTENDS = stackOption("frontend").values.filter((value) => value.generated).map((value) => value.value);
|
|
@@ -598,16 +1129,49 @@ function umamiEnv(databaseUrl, secrets) {
|
|
|
598
1129
|
UMAMI_DATABASE_URL: umamiDatabaseUrl(databaseUrl)
|
|
599
1130
|
};
|
|
600
1131
|
}
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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"
|
|
1132
|
+
const CACHE_URLS = {
|
|
1133
|
+
database: "db://",
|
|
1134
|
+
valkey: "redis://cache:6379/0",
|
|
1135
|
+
off: "locmem://"
|
|
610
1136
|
};
|
|
1137
|
+
const TASK_ENV = {
|
|
1138
|
+
database: {
|
|
1139
|
+
TASKS_URL: "db://",
|
|
1140
|
+
TASK_WORKERS: "1"
|
|
1141
|
+
},
|
|
1142
|
+
valkey: {
|
|
1143
|
+
TASKS_URL: "redis://cache:6379/1",
|
|
1144
|
+
TASK_WORKERS: "1"
|
|
1145
|
+
},
|
|
1146
|
+
none: {
|
|
1147
|
+
TASKS_URL: "immediate://",
|
|
1148
|
+
TASK_WORKERS: "0"
|
|
1149
|
+
}
|
|
1150
|
+
};
|
|
1151
|
+
/** The `.env` values of a cache and a task queue: CACHE_URL, TASKS_URL and TASK_WORKERS. */
|
|
1152
|
+
function cacheEnv(stack) {
|
|
1153
|
+
return {
|
|
1154
|
+
CACHE_URL: CACHE_URLS[stack.cache],
|
|
1155
|
+
...TASK_ENV[taskQueue(stack)]
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
/** Whether `.env` values use the Valkey service (the `valkey` profile). */
|
|
1159
|
+
function usesValkey(env) {
|
|
1160
|
+
return [env.CACHE_URL, env.TASKS_URL].some((url) => /^(redis|rediss|valkey|valkeys):\/\/cache[:/]/.test(url ?? ""));
|
|
1161
|
+
}
|
|
1162
|
+
/** The local S3 service of the `s3` profile, published on `port`. It accepts any credentials. */
|
|
1163
|
+
function localS3(port = 8333) {
|
|
1164
|
+
return {
|
|
1165
|
+
S3_BUCKET: "media",
|
|
1166
|
+
S3_ENDPOINT_URL: "http://s3:8333",
|
|
1167
|
+
S3_REGION: "us-east-1",
|
|
1168
|
+
S3_ACCESS_KEY_ID: "local",
|
|
1169
|
+
S3_SECRET_ACCESS_KEY: "local",
|
|
1170
|
+
S3_PUBLIC_URL: `http://localhost:${port}/media`,
|
|
1171
|
+
S3_CREATE_BUCKET: "1"
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
const LOCAL_S3 = localS3();
|
|
611
1175
|
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
1176
|
function defaultTemplatesDir() {
|
|
613
1177
|
return fileURLToPath(new URL("../templates", import.meta.url));
|
|
@@ -670,6 +1234,7 @@ function scaffold(options) {
|
|
|
670
1234
|
cpSync(join(templates, "backend"), join(target, "backend"), { recursive: true });
|
|
671
1235
|
cpSync(frontendTemplate, join(target, "frontend"), { recursive: true });
|
|
672
1236
|
renameSync(join(target, "gitignore"), join(target, ".gitignore"));
|
|
1237
|
+
makeExecutable(target);
|
|
673
1238
|
const about = join(target, "frontend", "template.json");
|
|
674
1239
|
const frontend = JSON.parse(readFileSync(about, "utf8"));
|
|
675
1240
|
rmSync(about);
|
|
@@ -680,7 +1245,9 @@ function scaffold(options) {
|
|
|
680
1245
|
frontend_url: frontend.url,
|
|
681
1246
|
frontend_security: frontend.security,
|
|
682
1247
|
cli_version: CLI_VERSION,
|
|
683
|
-
preset: options.preset ??
|
|
1248
|
+
preset: options.preset ?? encodePreset(DEFAULT_STACK),
|
|
1249
|
+
site_port: String(options.ports?.SITE_PORT ?? 3e3),
|
|
1250
|
+
mail_port: String(options.ports?.MAIL_PORT ?? 8025)
|
|
684
1251
|
};
|
|
685
1252
|
const files = walk(target);
|
|
686
1253
|
for (const file of files) {
|
|
@@ -709,7 +1276,14 @@ function scaffold(options) {
|
|
|
709
1276
|
profiles.push("analytics");
|
|
710
1277
|
env = setEnvValues(env, umamiEnv(parseEnv(env).DATABASE_URL ?? "", secrets));
|
|
711
1278
|
}
|
|
1279
|
+
const cache = cacheEnv({
|
|
1280
|
+
cache: options.cache ?? "database",
|
|
1281
|
+
tasks: options.tasks ?? "huey"
|
|
1282
|
+
});
|
|
1283
|
+
env = setEnvValues(env, cache);
|
|
1284
|
+
if (usesValkey(cache)) profiles.push("valkey");
|
|
712
1285
|
env = setEnvValues(env, { COMPOSE_PROFILES: profiles.join(",") });
|
|
1286
|
+
if (options.ports) env = setEnvValues(env, portValues(parseEnv(env), options.ports));
|
|
713
1287
|
if (!options.demoContent) env = setEnvValues(env, { SEED_DEMO_CONTENT: "0" });
|
|
714
1288
|
writeFileSync(join(target, ".env"), env, { mode: 384 });
|
|
715
1289
|
writeFileSync(join(target, ".env.example"), envExample);
|
|
@@ -791,6 +1365,15 @@ function relativeDirectory(directory) {
|
|
|
791
1365
|
return path.startsWith("..") ? directory : path;
|
|
792
1366
|
}
|
|
793
1367
|
// src/production.ts
|
|
1368
|
+
/** The S3 settings that uploads to S3 need and that are empty. */
|
|
1369
|
+
function missingS3(env) {
|
|
1370
|
+
return [
|
|
1371
|
+
"S3_BUCKET",
|
|
1372
|
+
"S3_REGION",
|
|
1373
|
+
"S3_ACCESS_KEY_ID",
|
|
1374
|
+
"S3_SECRET_ACCESS_KEY"
|
|
1375
|
+
].filter((name) => !env[name]);
|
|
1376
|
+
}
|
|
794
1377
|
function validateDomain(domain) {
|
|
795
1378
|
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
1379
|
}
|
|
@@ -847,8 +1430,9 @@ function productionEnv(devEnv, options) {
|
|
|
847
1430
|
S3_PUBLIC_URL: "",
|
|
848
1431
|
S3_CREATE_BUCKET: "0"
|
|
849
1432
|
});
|
|
850
|
-
todo.push("Set the S3_* variables to your S3 service, or leave them empty to keep uploads on the server.");
|
|
1433
|
+
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
1434
|
}
|
|
1435
|
+
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
1436
|
if (!current.ADMIN_EMAIL || current.ADMIN_EMAIL.endsWith("@example.com")) todo.push("Set ADMIN_EMAIL to your address.");
|
|
853
1437
|
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
1438
|
return {
|
|
@@ -857,4 +1441,4 @@ function productionEnv(devEnv, options) {
|
|
|
857
1441
|
todo
|
|
858
1442
|
};
|
|
859
1443
|
}
|
|
860
|
-
export {
|
|
1444
|
+
export { isDockerDown as $, projectUrls as A, encodePreset as B, RUN_ENV as C, findProjectDirectory as D, composeOutput as E, NAMED_PRESETS as F, chooseFreePorts as G, resolvePreset as H, PROMPT_ORDER as I, portValues as J, describeMove as K, STACK_OPTIONS as L, readProject as M, runningServices as N, parseProfiles as O, DEFAULT_STACK as P, dockerRunning as Q, applies as R, RUN_DOMAIN as S, compose as T, stackOption as U, pendingChoices as V, DEV_PORTS as W, checkTools as X, runDoctor as Y, dockerNotRunning as Z, validateTimeZone as _, LOCAL_S3 as a, generateProjectSecrets as b, lockBackend as c, scaffold as d, parseEnv as et, timeZones as f, validateProjectName as g, usesValkey as h, FRONTENDS as i, readLocalRun as j, projectStack as k, normalizeDatabaseUrl as l, umamiEnv as m, productionEnv as n, setEnvValues as nt, cacheEnv as o, toProjectName as p, envPorts as q, validateDomain as r, localS3 as s, missingS3 as t, quoteEnvValue as tt, relativeDirectory as u, CLI_VERSION as v, activeProject as w, generateSecret as x, PACKAGE_NAME as y, decodePreset as z };
|