@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.
- package/dist/bin.js +470 -64
- package/dist/index.d.ts +51 -6
- package/dist/index.js +1 -1
- package/dist/{production-DIj9yRcg.js → production-DBpWWx6i.js} +380 -39
- package/package.json +1 -1
- 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
package/dist/bin.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { C as
|
|
2
|
+
import { A as generateSecret, B as isDockerDown, C as applies, D as resolvePreset, E as pendingChoices, F as portValues, H as quoteEnvValue, I as runDoctor, L as checkTools, M as chooseFreePorts, N as describeMove, O as stackOption, P as envPorts, R as dockerNotRunning, S as STACK_OPTIONS, T as encodePreset, U as setEnvValues, V as parseEnv, _ as validateTimeZone, a as LOCAL_S3, b as NAMED_PRESETS, c as lockBackend, d as scaffold, f as timeZones, g as validateProjectName, h as usesValkey, j as DEV_PORTS, k as generateProjectSecrets, m as umamiEnv, n as productionEnv$1, o as cacheEnv, p as toProjectName, r as validateDomain, s as localS3, t as missingS3, u as relativeDirectory, v as CLI_VERSION, w as decodePreset, x as PROMPT_ORDER, y as DEFAULT_STACK, z as dockerRunning } from "./production-DBpWWx6i.js";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { basename, delimiter, dirname, join, resolve } from "node:path";
|
|
5
|
+
import { basename, delimiter, dirname, join, relative, resolve } from "node:path";
|
|
6
6
|
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
7
7
|
import pc from "picocolors";
|
|
8
8
|
import tab from "@bomb.sh/tab/commander";
|
|
@@ -136,7 +136,7 @@ function probeFor(definition, ports) {
|
|
|
136
136
|
}
|
|
137
137
|
/** Plans the Bunny app for a resolved Compose project. */
|
|
138
138
|
function planApp(project, options) {
|
|
139
|
-
const entries = Object.entries(project.services).sort(([a], [b]) => a.localeCompare(b));
|
|
139
|
+
const entries = Object.entries(project.services).filter(([, definition]) => definition.deploy?.replicas !== 0).sort(([a], [b]) => a.localeCompare(b));
|
|
140
140
|
if (!entries.length) throw new Error("compose.yaml has no services for production.");
|
|
141
141
|
const names = /* @__PURE__ */ new Map();
|
|
142
142
|
for (const [service, definition] of entries) names.set(service, hostNames(service, definition));
|
|
@@ -1126,6 +1126,7 @@ var BunnyClient = class {
|
|
|
1126
1126
|
async docker(args, options = {}) {
|
|
1127
1127
|
const result = await this.run("docker", args, options);
|
|
1128
1128
|
if (result.code === 0) return result.stdout;
|
|
1129
|
+
if (isDockerDown(`${result.stderr}\n${result.stdout}`)) throw new Error(dockerNotRunning());
|
|
1129
1130
|
const last = (result.stderr.trim() || result.stdout.trim()).split("\n").at(-1) ?? "";
|
|
1130
1131
|
throw new Error(`docker ${args[0]} failed (exit ${result.code})${last ? `: ${last}` : "."}`);
|
|
1131
1132
|
}
|
|
@@ -2573,20 +2574,41 @@ function projectManifest(project) {
|
|
|
2573
2574
|
* generated have the default in their preset without running it.
|
|
2574
2575
|
*/
|
|
2575
2576
|
function projectStack(project) {
|
|
2576
|
-
const
|
|
2577
|
+
const fromEnv = {
|
|
2578
|
+
analytics: project.profiles.includes("analytics") ? "umami" : "off",
|
|
2579
|
+
...project.env.CACHE_URL === void 0 ? {} : cacheStack(project.env)
|
|
2580
|
+
};
|
|
2577
2581
|
const { preset } = projectManifest(project);
|
|
2578
2582
|
if (preset) try {
|
|
2579
2583
|
return {
|
|
2580
2584
|
...decodePreset(preset),
|
|
2581
|
-
|
|
2585
|
+
...fromEnv
|
|
2582
2586
|
};
|
|
2583
2587
|
} catch {}
|
|
2584
2588
|
return {
|
|
2585
2589
|
...DEFAULT_STACK,
|
|
2586
|
-
|
|
2590
|
+
...fromEnv,
|
|
2587
2591
|
storage: project.profiles.includes("s3") ? "s3" : "local"
|
|
2588
2592
|
};
|
|
2589
2593
|
}
|
|
2594
|
+
const REDIS = /^(redis|rediss|valkey|valkeys):/;
|
|
2595
|
+
/**
|
|
2596
|
+
* The cache and the tasks a project's `.env` runs. Projects from before 0.7
|
|
2597
|
+
* have no TASKS_URL: their tasks follow a Redis cache, like the backend does.
|
|
2598
|
+
*/
|
|
2599
|
+
function cacheStack(env) {
|
|
2600
|
+
const cacheUrl = env.CACHE_URL ?? "";
|
|
2601
|
+
const cache = cacheUrl.startsWith("db:") ? "database" : REDIS.test(cacheUrl) ? "valkey" : "off";
|
|
2602
|
+
const tasksUrl = env.TASKS_URL || (cache === "valkey" ? cacheUrl : "immediate://");
|
|
2603
|
+
if (tasksUrl.startsWith("immediate:")) return {
|
|
2604
|
+
cache,
|
|
2605
|
+
tasks: "off"
|
|
2606
|
+
};
|
|
2607
|
+
return {
|
|
2608
|
+
cache,
|
|
2609
|
+
tasks: REDIS.test(tasksUrl) && cache !== "valkey" ? "huey-valkey" : "huey"
|
|
2610
|
+
};
|
|
2611
|
+
}
|
|
2590
2612
|
/** The URLs of a running project. */
|
|
2591
2613
|
function projectUrls(project) {
|
|
2592
2614
|
if (project.prod) {
|
|
@@ -2599,23 +2621,25 @@ function projectUrls(project) {
|
|
|
2599
2621
|
url: `${site}/admin/`
|
|
2600
2622
|
}];
|
|
2601
2623
|
}
|
|
2624
|
+
const ports = envPorts(project.env);
|
|
2625
|
+
const site = (project.env.SITE_URL || `http://localhost:${ports.SITE_PORT}`).replace(/\/$/, "");
|
|
2602
2626
|
const urls = [
|
|
2603
2627
|
{
|
|
2604
2628
|
label: "Site",
|
|
2605
|
-
url:
|
|
2629
|
+
url: site
|
|
2606
2630
|
},
|
|
2607
2631
|
{
|
|
2608
2632
|
label: "Admin",
|
|
2609
|
-
url:
|
|
2633
|
+
url: `${site}/admin/`
|
|
2610
2634
|
},
|
|
2611
2635
|
{
|
|
2612
2636
|
label: "Mail",
|
|
2613
|
-
url:
|
|
2637
|
+
url: `http://localhost:${ports.MAIL_PORT}`
|
|
2614
2638
|
}
|
|
2615
2639
|
];
|
|
2616
2640
|
if (project.profiles.includes("s3")) urls.push({
|
|
2617
2641
|
label: "S3",
|
|
2618
|
-
url:
|
|
2642
|
+
url: `http://localhost:${ports.S3_PORT}`
|
|
2619
2643
|
});
|
|
2620
2644
|
return urls;
|
|
2621
2645
|
}
|
|
@@ -3422,14 +3446,23 @@ async function offerGlobalInstall() {
|
|
|
3422
3446
|
return true;
|
|
3423
3447
|
}
|
|
3424
3448
|
// src/commands/init.ts
|
|
3425
|
-
/**
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3449
|
+
/**
|
|
3450
|
+
* The prompt choices of an option; values this version does not generate yet
|
|
3451
|
+
* are shown but disabled. The tasks follow the cache chosen before them: Huey
|
|
3452
|
+
* keeps its queue in the database, or in Valkey with the Valkey cache.
|
|
3453
|
+
*/
|
|
3454
|
+
function choices(key, stack = {}) {
|
|
3455
|
+
return stackOption(key).values.flatMap((value, index) => {
|
|
3456
|
+
let hint = value.generated || index === 0 ? value.hint : "coming soon";
|
|
3457
|
+
if (key === "tasks" && value.value === "huey") hint = stack.cache === "valkey" ? "a worker container, the queue in Valkey" : "a worker container, the queue in Postgres";
|
|
3458
|
+
if (key === "tasks" && value.value === "huey-valkey" && stack.cache === "valkey") return [];
|
|
3459
|
+
return [{
|
|
3460
|
+
value: value.value,
|
|
3461
|
+
label: value.label,
|
|
3462
|
+
hint,
|
|
3463
|
+
disabled: !value.generated && index > 0
|
|
3464
|
+
}];
|
|
3465
|
+
});
|
|
3433
3466
|
}
|
|
3434
3467
|
/**
|
|
3435
3468
|
* The stack from, in order of precedence, the flags, the preset, the prompts
|
|
@@ -3445,8 +3478,8 @@ async function chooseStack(options, interactive, askDatabase) {
|
|
|
3445
3478
|
}
|
|
3446
3479
|
const stack = { ...DEFAULT_STACK };
|
|
3447
3480
|
let databaseUrl;
|
|
3448
|
-
for (const
|
|
3449
|
-
const
|
|
3481
|
+
for (const key of PROMPT_ORDER) {
|
|
3482
|
+
const option = stackOption(key);
|
|
3450
3483
|
if (options.fromCli(key)) stack[key] = options[key];
|
|
3451
3484
|
else if (key === "storage" && options.fromCli("localS3") && options.localS3) stack[key] = "s3";
|
|
3452
3485
|
else if (preset) stack[key] = preset[key];
|
|
@@ -3456,11 +3489,14 @@ async function chooseStack(options, interactive, askDatabase) {
|
|
|
3456
3489
|
if ("databaseUrl" in answer) databaseUrl = answer.databaseUrl;
|
|
3457
3490
|
else stack[key] = answer.postgres;
|
|
3458
3491
|
} else if (key === "postgres" && options.databaseUrl) continue;
|
|
3459
|
-
else
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3492
|
+
else {
|
|
3493
|
+
const initialValue = key === "storage" && stack.media === "s3" ? "s3" : stack[key];
|
|
3494
|
+
stack[key] = unwrap(await p.select({
|
|
3495
|
+
message: option.message,
|
|
3496
|
+
options: choices(key, stack),
|
|
3497
|
+
initialValue
|
|
3498
|
+
}));
|
|
3499
|
+
}
|
|
3464
3500
|
}
|
|
3465
3501
|
return {
|
|
3466
3502
|
stack,
|
|
@@ -3555,6 +3591,8 @@ async function init(directoryArgument, options) {
|
|
|
3555
3591
|
message: "Add demo content? (example pages and posts)",
|
|
3556
3592
|
initialValue: true
|
|
3557
3593
|
}));
|
|
3594
|
+
const ports = await chooseFreePorts({}, stack.storage === "s3" ? ["s3"] : []);
|
|
3595
|
+
for (const move of ports.moves) p.log.info(describeMove(move));
|
|
3558
3596
|
const spinner = p.spinner();
|
|
3559
3597
|
spinner.start("Creating the project");
|
|
3560
3598
|
let result;
|
|
@@ -3567,8 +3605,11 @@ async function init(directoryArgument, options) {
|
|
|
3567
3605
|
databaseUrl,
|
|
3568
3606
|
localS3: stack.storage === "s3",
|
|
3569
3607
|
analytics: stack.analytics === "umami",
|
|
3608
|
+
cache: stack.cache,
|
|
3609
|
+
tasks: stack.tasks,
|
|
3570
3610
|
preset: encodePreset(stack),
|
|
3571
3611
|
timeZone,
|
|
3612
|
+
ports: ports.ports,
|
|
3572
3613
|
link: options.link
|
|
3573
3614
|
});
|
|
3574
3615
|
} catch (error) {
|
|
@@ -3603,22 +3644,24 @@ async function init(directoryArgument, options) {
|
|
|
3603
3644
|
} catch {
|
|
3604
3645
|
p.log.warn("Could not commit the project with git. Commit it yourself.");
|
|
3605
3646
|
}
|
|
3647
|
+
if (options.installPrompt !== false && !options.yes) await offerGlobalInstall();
|
|
3648
|
+
const startCommand = isOnPath() ? "dstack dev" : "docker compose up";
|
|
3606
3649
|
p.note([
|
|
3607
3650
|
command(`cd ${relativeDirectory(target)}`),
|
|
3608
|
-
command(
|
|
3651
|
+
command(startCommand),
|
|
3609
3652
|
"",
|
|
3610
|
-
`${pc.bold("Site")} ${url(
|
|
3611
|
-
`${pc.bold("Admin")} ${url(
|
|
3612
|
-
`${pc.bold("Mail")} ${url(
|
|
3653
|
+
`${pc.bold("Site")} ${url(`http://localhost:${ports.ports.SITE_PORT}`)}`,
|
|
3654
|
+
`${pc.bold("Admin")} ${url(`http://localhost:${ports.ports.SITE_PORT}/admin/`)}`,
|
|
3655
|
+
`${pc.bold("Mail")} ${url(`http://localhost:${ports.ports.MAIL_PORT}`)}`,
|
|
3613
3656
|
"",
|
|
3614
3657
|
`Admin user ${secret(result.adminUsername)}, password ${secret(result.adminPassword)}`,
|
|
3615
3658
|
pc.dim("The password is in .env as ADMIN_PASSWORD. Change it after you sign in.")
|
|
3616
3659
|
].join("\n"), "Next steps");
|
|
3617
3660
|
if (pending.length) p.log.info(`Coming in a later version, so not in this project yet: ${pending.map((item) => `${stackOption(item.key).message.toLowerCase()} (${stack[item.key]})`).join(", ")}.`);
|
|
3661
|
+
if (stack.media === "s3") p.log.info(`Uploads go to S3 in production. Before the first deploy, ${command("dstack production")} asks for the S3 settings; ${command("dstack settings")} changes them later.`);
|
|
3618
3662
|
p.log.message(`Create the same stack with ${command(`--preset ${encodePreset(stack)}`)}`);
|
|
3619
|
-
if (options.start && !dockerReady) p.outro(pc.yellow(`Docker is not ready, so the project was not started. Fix the warnings above and run ${command(
|
|
3663
|
+
if (options.start && !dockerReady) p.outro(pc.yellow(`Docker is not ready, so the project was not started. Fix the warnings above and run ${command(startCommand)}.`));
|
|
3620
3664
|
else if (options.start) {
|
|
3621
|
-
if (options.installPrompt !== false && !options.yes) await offerGlobalInstall();
|
|
3622
3665
|
p.outro(`Starting the project with ${command("docker compose up")}`);
|
|
3623
3666
|
execFileSync("docker", ["compose", "up"], {
|
|
3624
3667
|
cwd: target,
|
|
@@ -3637,8 +3680,9 @@ async function preset(code, options) {
|
|
|
3637
3680
|
} catch (error) {
|
|
3638
3681
|
fail(error.message);
|
|
3639
3682
|
}
|
|
3640
|
-
const lines =
|
|
3641
|
-
const
|
|
3683
|
+
const lines = PROMPT_ORDER.filter((key) => applies(key, stack)).map((key) => {
|
|
3684
|
+
const option = stackOption(key);
|
|
3685
|
+
const value = option.values.find((candidate) => candidate.value === stack[key]);
|
|
3642
3686
|
return `${pc.bold(option.message.padEnd(24))}${value?.label ?? ""}`;
|
|
3643
3687
|
});
|
|
3644
3688
|
p.note(lines.join("\n"), command(`--preset ${encodePreset(stack)}`));
|
|
@@ -3649,6 +3693,295 @@ async function preset(code, options) {
|
|
|
3649
3693
|
const encoded = encodePreset(stack);
|
|
3650
3694
|
p.outro(`Create this stack with ${command(`${selfCommand()} init --preset ${encoded}`)}`);
|
|
3651
3695
|
}
|
|
3696
|
+
// src/settings.ts
|
|
3697
|
+
function validateEmail(value) {
|
|
3698
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? void 0 : "Enter an email address, such as you@example.com.";
|
|
3699
|
+
}
|
|
3700
|
+
function validateHost(value) {
|
|
3701
|
+
return /^[a-z0-9.-]+$/i.test(value) && value.includes(".") ? void 0 : "Enter a host name, such as smtp.example.com.";
|
|
3702
|
+
}
|
|
3703
|
+
function validatePort(value) {
|
|
3704
|
+
const port = Number(value);
|
|
3705
|
+
return Number.isInteger(port) && port > 0 && port < 65536 ? void 0 : "Enter a port, such as 587.";
|
|
3706
|
+
}
|
|
3707
|
+
function validateUrl(value) {
|
|
3708
|
+
if (!value) return void 0;
|
|
3709
|
+
return /^https?:\/\/[^\s/]+/.test(value) ? void 0 : "Enter a URL that starts with https://.";
|
|
3710
|
+
}
|
|
3711
|
+
const required = (label) => (value) => value.trim() ? void 0 : `Enter the ${label}.`;
|
|
3712
|
+
/**
|
|
3713
|
+
* An SMTP URL for EMAIL_URL: smtps:// on port 465, smtp:// on 25, otherwise
|
|
3714
|
+
* smtp+tls:// (STARTTLS), with the user name and password URL-encoded.
|
|
3715
|
+
*/
|
|
3716
|
+
function smtpUrl(parts) {
|
|
3717
|
+
const port = Number(parts.port || "587");
|
|
3718
|
+
const scheme = port === 465 ? "smtps" : port === 25 ? "smtp" : "smtp+tls";
|
|
3719
|
+
const user = parts.username ? encodeURIComponent(parts.username) : "";
|
|
3720
|
+
const password = parts.password ? `:${encodeURIComponent(parts.password)}` : "";
|
|
3721
|
+
return `${scheme}://${user ? `${user}${password}@` : ""}${parts.host}:${port}`;
|
|
3722
|
+
}
|
|
3723
|
+
/** The parts of an SMTP URL, for the current answers. */
|
|
3724
|
+
function smtpParts(value) {
|
|
3725
|
+
try {
|
|
3726
|
+
const url = new URL(value ?? "");
|
|
3727
|
+
return {
|
|
3728
|
+
host: url.hostname,
|
|
3729
|
+
port: url.port,
|
|
3730
|
+
username: decodeURIComponent(url.username),
|
|
3731
|
+
password: decodeURIComponent(url.password)
|
|
3732
|
+
};
|
|
3733
|
+
} catch {
|
|
3734
|
+
return {};
|
|
3735
|
+
}
|
|
3736
|
+
}
|
|
3737
|
+
const placeholderMail = (value) => !value || value.includes("mailpit") || value.includes("example.com") || value.startsWith("console:");
|
|
3738
|
+
const SETTING_GROUPS = [
|
|
3739
|
+
{
|
|
3740
|
+
title: "Admin email",
|
|
3741
|
+
names: ["ADMIN_EMAIL"],
|
|
3742
|
+
applies: () => true,
|
|
3743
|
+
missing: (env) => !env.ADMIN_EMAIL || env.ADMIN_EMAIL.endsWith("@example.com") ? ["ADMIN_EMAIL"] : [],
|
|
3744
|
+
fields: [{
|
|
3745
|
+
name: "ADMIN_EMAIL",
|
|
3746
|
+
message: "Your email address, for the admin user",
|
|
3747
|
+
placeholder: "you@example.com",
|
|
3748
|
+
validate: validateEmail
|
|
3749
|
+
}],
|
|
3750
|
+
values: (answers) => ({ ADMIN_EMAIL: answers.ADMIN_EMAIL ?? "" })
|
|
3751
|
+
},
|
|
3752
|
+
{
|
|
3753
|
+
title: "Outgoing mail",
|
|
3754
|
+
names: ["EMAIL_URL"],
|
|
3755
|
+
applies: () => true,
|
|
3756
|
+
missing: (env) => placeholderMail(env.EMAIL_URL) ? ["EMAIL_URL"] : [],
|
|
3757
|
+
fields: [
|
|
3758
|
+
{
|
|
3759
|
+
name: "host",
|
|
3760
|
+
message: "SMTP server",
|
|
3761
|
+
placeholder: "smtp.example.com",
|
|
3762
|
+
validate: validateHost
|
|
3763
|
+
},
|
|
3764
|
+
{
|
|
3765
|
+
name: "port",
|
|
3766
|
+
message: "SMTP port",
|
|
3767
|
+
defaultValue: "587",
|
|
3768
|
+
validate: validatePort
|
|
3769
|
+
},
|
|
3770
|
+
{
|
|
3771
|
+
name: "username",
|
|
3772
|
+
message: "SMTP user name",
|
|
3773
|
+
optional: true
|
|
3774
|
+
},
|
|
3775
|
+
{
|
|
3776
|
+
name: "password",
|
|
3777
|
+
message: "SMTP password",
|
|
3778
|
+
secret: true,
|
|
3779
|
+
optional: true
|
|
3780
|
+
}
|
|
3781
|
+
],
|
|
3782
|
+
current: (env, field) => placeholderMail(env.EMAIL_URL) ? void 0 : smtpParts(env.EMAIL_URL)[field],
|
|
3783
|
+
values: (answers) => ({ EMAIL_URL: smtpUrl({
|
|
3784
|
+
host: answers.host ?? "",
|
|
3785
|
+
port: answers.port ?? "587",
|
|
3786
|
+
username: answers.username,
|
|
3787
|
+
password: answers.password
|
|
3788
|
+
}) })
|
|
3789
|
+
},
|
|
3790
|
+
{
|
|
3791
|
+
title: "S3 for uploads",
|
|
3792
|
+
names: [
|
|
3793
|
+
"S3_BUCKET",
|
|
3794
|
+
"S3_ENDPOINT_URL",
|
|
3795
|
+
"S3_REGION",
|
|
3796
|
+
"S3_ACCESS_KEY_ID",
|
|
3797
|
+
"S3_SECRET_ACCESS_KEY",
|
|
3798
|
+
"S3_PUBLIC_URL"
|
|
3799
|
+
],
|
|
3800
|
+
applies: (env, context) => context.media === "s3" || Boolean(env.S3_BUCKET),
|
|
3801
|
+
missing: missingS3,
|
|
3802
|
+
fields: [
|
|
3803
|
+
{
|
|
3804
|
+
name: "S3_BUCKET",
|
|
3805
|
+
message: "S3 bucket",
|
|
3806
|
+
placeholder: "example-media",
|
|
3807
|
+
validate: required("bucket")
|
|
3808
|
+
},
|
|
3809
|
+
{
|
|
3810
|
+
name: "S3_ENDPOINT_URL",
|
|
3811
|
+
message: "S3 endpoint, empty for AWS",
|
|
3812
|
+
placeholder: "https://fsn1.your-objectstorage.com",
|
|
3813
|
+
optional: true,
|
|
3814
|
+
validate: validateUrl
|
|
3815
|
+
},
|
|
3816
|
+
{
|
|
3817
|
+
name: "S3_REGION",
|
|
3818
|
+
message: "S3 region",
|
|
3819
|
+
placeholder: "eu-central-1",
|
|
3820
|
+
validate: required("region")
|
|
3821
|
+
},
|
|
3822
|
+
{
|
|
3823
|
+
name: "S3_ACCESS_KEY_ID",
|
|
3824
|
+
message: "S3 access key ID",
|
|
3825
|
+
validate: required("access key ID")
|
|
3826
|
+
},
|
|
3827
|
+
{
|
|
3828
|
+
name: "S3_SECRET_ACCESS_KEY",
|
|
3829
|
+
message: "S3 secret access key",
|
|
3830
|
+
secret: true,
|
|
3831
|
+
validate: required("secret access key")
|
|
3832
|
+
},
|
|
3833
|
+
{
|
|
3834
|
+
name: "S3_PUBLIC_URL",
|
|
3835
|
+
message: "Public URL of the files, empty when it is the endpoint",
|
|
3836
|
+
placeholder: "https://media.example.com",
|
|
3837
|
+
optional: true,
|
|
3838
|
+
validate: validateUrl
|
|
3839
|
+
}
|
|
3840
|
+
],
|
|
3841
|
+
current: (env, field) => env[field],
|
|
3842
|
+
values: (answers) => Object.fromEntries([
|
|
3843
|
+
"S3_BUCKET",
|
|
3844
|
+
"S3_ENDPOINT_URL",
|
|
3845
|
+
"S3_REGION",
|
|
3846
|
+
"S3_ACCESS_KEY_ID",
|
|
3847
|
+
"S3_SECRET_ACCESS_KEY",
|
|
3848
|
+
"S3_PUBLIC_URL"
|
|
3849
|
+
].map((name) => [name, answers[name] ?? ""]))
|
|
3850
|
+
}
|
|
3851
|
+
];
|
|
3852
|
+
/** The groups of a production `.env` to ask for: the missing ones, or with `all` every one that applies. */
|
|
3853
|
+
function settingsToAsk(env, context, all = false) {
|
|
3854
|
+
return SETTING_GROUPS.filter((group) => group.applies(env, context) && (all || group.missing(env).length));
|
|
3855
|
+
}
|
|
3856
|
+
/** Every `.env` variable `--set` takes. */
|
|
3857
|
+
const SETTING_NAMES = SETTING_GROUPS.flatMap((group) => group.names);
|
|
3858
|
+
/** Parses `--set NAME=value` arguments. Throws with a one-line reason. */
|
|
3859
|
+
function parseAssignments(items) {
|
|
3860
|
+
const values = {};
|
|
3861
|
+
for (const item of items) {
|
|
3862
|
+
const index = item.indexOf("=");
|
|
3863
|
+
const name = index > 0 ? item.slice(0, index) : "";
|
|
3864
|
+
if (!SETTING_NAMES.includes(name)) throw new Error(`${JSON.stringify(item)} is not NAME=value for one of ${SETTING_NAMES.join(", ")}.`);
|
|
3865
|
+
const value = item.slice(index + 1);
|
|
3866
|
+
if (/[\r\n]/.test(value)) throw new Error(`The value of ${name} has a line break.`);
|
|
3867
|
+
if (name === "ADMIN_EMAIL" && validateEmail(value)) throw new Error(`ADMIN_EMAIL: ${validateEmail(value)}`);
|
|
3868
|
+
values[name] = value;
|
|
3869
|
+
}
|
|
3870
|
+
return values;
|
|
3871
|
+
}
|
|
3872
|
+
/** The items of `production`'s to-do list that the settings now in `env` have done. */
|
|
3873
|
+
function remainingTodo(todo, env) {
|
|
3874
|
+
return todo.filter((item) => {
|
|
3875
|
+
const group = SETTING_GROUPS.find((candidate) => candidate.names.some((name) => item.includes(name.startsWith("S3_") ? "S3_" : name)));
|
|
3876
|
+
return !group || group.missing(env).length > 0;
|
|
3877
|
+
});
|
|
3878
|
+
}
|
|
3879
|
+
// src/commands/settings.ts
|
|
3880
|
+
/** The `media` stack option of the project, from the preset in package.json. */
|
|
3881
|
+
function projectContext(directory) {
|
|
3882
|
+
try {
|
|
3883
|
+
return { media: projectStack(readProject(directory)).media };
|
|
3884
|
+
} catch {
|
|
3885
|
+
return {};
|
|
3886
|
+
}
|
|
3887
|
+
}
|
|
3888
|
+
/** Asks for each field of the groups; returns the `.env` values. */
|
|
3889
|
+
async function askSettings(groups, env) {
|
|
3890
|
+
const values = {};
|
|
3891
|
+
for (const group of groups) {
|
|
3892
|
+
p.log.step(pc.bold(group.title));
|
|
3893
|
+
const answers = {};
|
|
3894
|
+
for (const field of group.fields) {
|
|
3895
|
+
const current = group.current?.(env, field.name);
|
|
3896
|
+
const check = (value) => {
|
|
3897
|
+
const text = (value ?? "").trim();
|
|
3898
|
+
if (!text && (field.optional || field.defaultValue || field.secret && current)) return void 0;
|
|
3899
|
+
return field.validate?.(text) ?? (text ? void 0 : `Enter the ${field.message.toLowerCase()}.`);
|
|
3900
|
+
};
|
|
3901
|
+
let answer;
|
|
3902
|
+
if (field.secret) {
|
|
3903
|
+
answer = unwrap(await p.password({
|
|
3904
|
+
message: current ? `${field.message} (empty keeps the current one)` : field.message,
|
|
3905
|
+
validate: check
|
|
3906
|
+
}));
|
|
3907
|
+
if (!answer && current) answer = current;
|
|
3908
|
+
} else answer = unwrap(await p.text({
|
|
3909
|
+
message: field.message,
|
|
3910
|
+
placeholder: field.placeholder ?? field.defaultValue,
|
|
3911
|
+
initialValue: current,
|
|
3912
|
+
defaultValue: field.defaultValue,
|
|
3913
|
+
validate: check
|
|
3914
|
+
}));
|
|
3915
|
+
answers[field.name] = (answer ?? field.defaultValue ?? "").trim();
|
|
3916
|
+
}
|
|
3917
|
+
Object.assign(values, group.values(answers));
|
|
3918
|
+
}
|
|
3919
|
+
return values;
|
|
3920
|
+
}
|
|
3921
|
+
/** Writes values to a `.env` file, quoted where Compose needs it. Throws for a value it can't write. */
|
|
3922
|
+
function writeSettings(path, values) {
|
|
3923
|
+
const quoted = Object.fromEntries(Object.entries(values).map(([name, value]) => [name, quoteEnvValue(value)]));
|
|
3924
|
+
writeFileSync(path, setEnvValues(readFileSync(path, "utf8"), quoted), { mode: 384 });
|
|
3925
|
+
}
|
|
3926
|
+
/** The production `.env` of a project: `--file`, `.env.production`, or `.env` on a server. */
|
|
3927
|
+
function targetFile(directory, file) {
|
|
3928
|
+
if (file) {
|
|
3929
|
+
const path = join(directory, file);
|
|
3930
|
+
if (!existsSync(path)) fail(`${path} does not exist.`);
|
|
3931
|
+
return path;
|
|
3932
|
+
}
|
|
3933
|
+
const production = join(directory, ".env.production");
|
|
3934
|
+
if (existsSync(production)) return production;
|
|
3935
|
+
const env = join(directory, ".env");
|
|
3936
|
+
if (existsSync(env) && parseProfiles(parseEnv(readFileSync(env, "utf8")).COMPOSE_PROFILES).includes("prod")) return env;
|
|
3937
|
+
fail(`There is no .env.production yet. Write it with ${command("dstack production")}.`);
|
|
3938
|
+
}
|
|
3939
|
+
/** `dstack settings`: ask for the missing production settings and credentials, and save them. */
|
|
3940
|
+
async function settings(options) {
|
|
3941
|
+
const interactive = isInteractive(options.yes);
|
|
3942
|
+
intro("Production settings");
|
|
3943
|
+
const directory = findProjectDirectory(options.dir ?? ".");
|
|
3944
|
+
if (!directory) fail("No DjangoStack project here: run this where compose.yaml and .env are.");
|
|
3945
|
+
const target = targetFile(directory, options.file);
|
|
3946
|
+
const name = relative(directory, target);
|
|
3947
|
+
let assigned;
|
|
3948
|
+
try {
|
|
3949
|
+
assigned = parseAssignments(options.set ?? []);
|
|
3950
|
+
} catch (error) {
|
|
3951
|
+
fail(error.message);
|
|
3952
|
+
}
|
|
3953
|
+
const env = {
|
|
3954
|
+
...parseEnv(readFileSync(target, "utf8")),
|
|
3955
|
+
...assigned
|
|
3956
|
+
};
|
|
3957
|
+
const context = projectContext(directory);
|
|
3958
|
+
const missing = settingsToAsk(env, context);
|
|
3959
|
+
let values = assigned;
|
|
3960
|
+
if (interactive) {
|
|
3961
|
+
const groups = settingsToAsk(env, context, options.all);
|
|
3962
|
+
if (!groups.length && !Object.keys(assigned).length) {
|
|
3963
|
+
p.outro(pc.green(`Nothing is missing in ${name}`));
|
|
3964
|
+
return;
|
|
3965
|
+
}
|
|
3966
|
+
if (groups.length && !options.all) p.log.info(`${name} needs ${groups.map((group) => group.title.toLowerCase()).join(", ")}.`);
|
|
3967
|
+
values = {
|
|
3968
|
+
...assigned,
|
|
3969
|
+
...await askSettings(groups, env)
|
|
3970
|
+
};
|
|
3971
|
+
} else if (missing.length) fail(`${name} misses ${missing.flatMap((group) => group.missing(env)).join(", ")}. Pass them with --set NAME=value.`);
|
|
3972
|
+
else if (!Object.keys(assigned).length) {
|
|
3973
|
+
p.outro(pc.green(`Nothing is missing in ${name}`));
|
|
3974
|
+
return;
|
|
3975
|
+
}
|
|
3976
|
+
try {
|
|
3977
|
+
writeSettings(target, values);
|
|
3978
|
+
} catch (error) {
|
|
3979
|
+
fail(error.message);
|
|
3980
|
+
}
|
|
3981
|
+
p.log.success(`Saved ${Object.keys(values).join(", ")} in ${pc.bold(name)}`);
|
|
3982
|
+
if (existsSync(join(directory, ".dstack", "deploy.json")) && target.endsWith(".env.production")) p.outro(`Send them to the platform with ${command("dstack deploy env push")}`);
|
|
3983
|
+
else p.outro(pc.green("Done"));
|
|
3984
|
+
}
|
|
3652
3985
|
// src/commands/production.ts
|
|
3653
3986
|
async function production(directoryArgument, options) {
|
|
3654
3987
|
const interactive = isInteractive(options.yes);
|
|
@@ -3667,18 +4000,36 @@ async function production(directoryArgument, options) {
|
|
|
3667
4000
|
validate: (v) => validateDomain(v ?? "")
|
|
3668
4001
|
}));
|
|
3669
4002
|
}
|
|
4003
|
+
const context = projectContext(directory);
|
|
3670
4004
|
let result;
|
|
3671
4005
|
try {
|
|
3672
4006
|
result = productionEnv$1(readFileSync(envPath, "utf8"), {
|
|
3673
4007
|
domain,
|
|
3674
|
-
emailUrl: options.emailUrl
|
|
4008
|
+
emailUrl: options.emailUrl,
|
|
4009
|
+
media: context.media
|
|
3675
4010
|
});
|
|
3676
4011
|
} catch (error) {
|
|
3677
4012
|
fail(error.message);
|
|
3678
4013
|
}
|
|
3679
4014
|
writeFileSync(target, result.text, { mode: 384 });
|
|
3680
4015
|
p.log.success(`Wrote ${pc.bold(relativeDirectory(target))} with new secrets for the server.`);
|
|
3681
|
-
|
|
4016
|
+
let todo = result.todo;
|
|
4017
|
+
const missing = settingsToAsk(parseEnv(result.text), context);
|
|
4018
|
+
if (interactive && missing.length) {
|
|
4019
|
+
if (unwrap(await p.confirm({
|
|
4020
|
+
message: `Set the ${missing.map((group) => group.title.toLowerCase()).join(", ")} now?`,
|
|
4021
|
+
initialValue: true
|
|
4022
|
+
}))) {
|
|
4023
|
+
try {
|
|
4024
|
+
writeSettings(target, await askSettings(missing, parseEnv(result.text)));
|
|
4025
|
+
} catch (error) {
|
|
4026
|
+
fail(error.message);
|
|
4027
|
+
}
|
|
4028
|
+
todo = remainingTodo(todo, parseEnv(readFileSync(target, "utf8")));
|
|
4029
|
+
}
|
|
4030
|
+
}
|
|
4031
|
+
if (todo.some((item) => SETTING_NAMES.some((name) => item.includes(name.startsWith("S3_") ? "S3_" : name)))) todo = [...todo, `Run ${command("dstack settings")} to set them.`];
|
|
4032
|
+
if (todo.length) p.note(todo.map((item) => `${pc.yellow("•")} ${item}`).join("\n"), "Before the first deploy");
|
|
3682
4033
|
p.note([
|
|
3683
4034
|
`Copy the project to the server, with ${pc.bold(".env.production")} as ${pc.bold(".env")}, then:`,
|
|
3684
4035
|
command("docker compose up -d --build"),
|
|
@@ -3690,7 +4041,13 @@ async function production(directoryArgument, options) {
|
|
|
3690
4041
|
}
|
|
3691
4042
|
// src/config.ts
|
|
3692
4043
|
/** The options `config` can switch in an existing project. The rest follow with code generation. */
|
|
3693
|
-
const SWITCHABLE = [
|
|
4044
|
+
const SWITCHABLE = [
|
|
4045
|
+
"storage",
|
|
4046
|
+
"analytics",
|
|
4047
|
+
"media",
|
|
4048
|
+
"cache",
|
|
4049
|
+
"tasks"
|
|
4050
|
+
];
|
|
3694
4051
|
const EMPTY_S3 = Object.fromEntries(Object.keys(LOCAL_S3).map((key) => [key, key === "S3_CREATE_BUCKET" ? "0" : ""]));
|
|
3695
4052
|
/**
|
|
3696
4053
|
* Applies a new stack to a project's `.env`. Throws with a one-line reason
|
|
@@ -3713,11 +4070,30 @@ function applyStack(envText, env, from, to) {
|
|
|
3713
4070
|
profiles = profiles.filter((profile) => profile !== "s3");
|
|
3714
4071
|
if (to.storage === "s3") profiles.push("s3");
|
|
3715
4072
|
text = setEnvValues(text, {
|
|
3716
|
-
...to.storage === "s3" ?
|
|
4073
|
+
...to.storage === "s3" ? localS3(envPorts(env).S3_PORT) : EMPTY_S3,
|
|
3717
4074
|
COMPOSE_PROFILES: profiles.join(",")
|
|
3718
4075
|
});
|
|
3719
4076
|
changes.push(to.storage === "s3" ? "Uploads go to the local S3 service (the s3 profile)." : "Uploads go to a Docker volume; the local S3 service is off.");
|
|
3720
4077
|
}
|
|
4078
|
+
if (key === "cache") {
|
|
4079
|
+
text = setEnvValues(text, { CACHE_URL: cacheEnv(to).CACHE_URL ?? "" });
|
|
4080
|
+
changes.push({
|
|
4081
|
+
database: "The cache is a table in the database.",
|
|
4082
|
+
valkey: "The cache is in Valkey (the valkey profile).",
|
|
4083
|
+
off: "The cache is per process; nothing is shared."
|
|
4084
|
+
}[to.cache]);
|
|
4085
|
+
}
|
|
4086
|
+
if (key === "tasks" || key === "cache" && from.tasks === "huey" && to.tasks === "huey") {
|
|
4087
|
+
const { CACHE_URL: _, ...tasks } = cacheEnv(to);
|
|
4088
|
+
const before = parseEnv(text).TASKS_URL;
|
|
4089
|
+
text = setEnvValues(text, tasks);
|
|
4090
|
+
if (before !== tasks.TASKS_URL) changes.push(to.tasks === "off" ? "Tasks run in the request; the worker is off, so there are no daily backups." : `The worker keeps its task queue in ${tasks.TASKS_URL === "db://" ? "the database" : "Valkey"}.`);
|
|
4091
|
+
}
|
|
4092
|
+
if (key === "cache" || key === "tasks") {
|
|
4093
|
+
profiles = profiles.filter((profile) => profile !== "valkey");
|
|
4094
|
+
if (usesValkey(parseEnv(text))) profiles.push("valkey");
|
|
4095
|
+
text = setEnvValues(text, { COMPOSE_PROFILES: profiles.join(",") });
|
|
4096
|
+
}
|
|
3721
4097
|
if (key === "analytics") {
|
|
3722
4098
|
profiles = profiles.filter((profile) => profile !== "analytics");
|
|
3723
4099
|
if (to.analytics === "umami") {
|
|
@@ -3737,6 +4113,7 @@ function applyStack(envText, env, from, to) {
|
|
|
3737
4113
|
changes.push("Page analytics are off; Umami's data stays in the database.");
|
|
3738
4114
|
}
|
|
3739
4115
|
}
|
|
4116
|
+
if (key === "media") changes.push(to.media === "s3" ? "Uploads in production go to S3; dstack settings asks for the S3 settings." : "Uploads in production stay on the server.");
|
|
3740
4117
|
}
|
|
3741
4118
|
return {
|
|
3742
4119
|
env: text,
|
|
@@ -3755,6 +4132,7 @@ function setManifestPreset(manifestText, preset) {
|
|
|
3755
4132
|
// src/commands/project.ts
|
|
3756
4133
|
function open(options) {
|
|
3757
4134
|
if (!has("docker", ["compose", "version"])) fail("Docker Compose v2 was not found. Install Docker: https://docs.docker.com/get-docker/");
|
|
4135
|
+
if (!dockerRunning()) fail(dockerNotRunning());
|
|
3758
4136
|
try {
|
|
3759
4137
|
return readProject(options.dir ?? ".");
|
|
3760
4138
|
} catch (error) {
|
|
@@ -3805,11 +4183,33 @@ async function run(options) {
|
|
|
3805
4183
|
if (project.localRun) p.log.message(pc.dim(`Caddy signs ${RUN_DOMAIN} with its own local CA, so the browser warns once. The admin password is in ${RUN_ENV}.`));
|
|
3806
4184
|
p.outro(`Stop it with ${command("dstack stop")}, follow the logs with ${command("dstack logs")}`);
|
|
3807
4185
|
}
|
|
4186
|
+
/**
|
|
4187
|
+
* Moves the development services to free ports when theirs are in use, in
|
|
4188
|
+
* `.env`, unless the project already runs. compose.yaml from before the
|
|
4189
|
+
* *_PORT variables has fixed ports, so it can only say which one is in use.
|
|
4190
|
+
*/
|
|
4191
|
+
async function freeDevPorts(project) {
|
|
4192
|
+
if (runningServices(project).length) return project;
|
|
4193
|
+
const { ports, moves, stuck } = await chooseFreePorts(project.env, project.profiles);
|
|
4194
|
+
const [blocked] = stuck;
|
|
4195
|
+
if (blocked) fail(`Port ${envPorts(project.env)[blocked.name]} of the ${blocked.label} is in use. Stop what uses it.`);
|
|
4196
|
+
if (!moves.length) return project;
|
|
4197
|
+
const composeText = readFileSync(join(project.directory, "compose.yaml"), "utf8");
|
|
4198
|
+
for (const move of moves) {
|
|
4199
|
+
const item = DEV_PORTS.find((candidate) => candidate.label === move.label);
|
|
4200
|
+
if (item && !composeText.includes(`\${${item.name}`)) fail(`Port ${move.from} of the ${move.label} is in use. Stop what uses it, or publish another port in compose.yaml.`);
|
|
4201
|
+
}
|
|
4202
|
+
const envPath = join(project.directory, ".env");
|
|
4203
|
+
writeFileSync(envPath, setEnvValues(readFileSync(envPath, "utf8"), portValues(project.env, ports)), { mode: 384 });
|
|
4204
|
+
for (const move of moves) p.log.info(`${describeMove(move)} Saved in .env.`);
|
|
4205
|
+
return readProject(project.directory);
|
|
4206
|
+
}
|
|
3808
4207
|
/** `dstack dev`: the dev profile in the foreground, with hot reload. */
|
|
3809
4208
|
async function dev(options) {
|
|
3810
|
-
|
|
4209
|
+
let project = open(options);
|
|
3811
4210
|
if (project.prod) fail(`This .env runs the prod profile. Use ${command("dstack run")}.`);
|
|
3812
4211
|
intro("Develop");
|
|
4212
|
+
project = await freeDevPorts(project);
|
|
3813
4213
|
urlsNote(project, "Starting");
|
|
3814
4214
|
p.log.message(pc.dim("Changes in backend/ and frontend/ reload. Press Ctrl+C to stop."));
|
|
3815
4215
|
exitWith(compose(project, ["up", ...options.build ? ["--build"] : []]));
|
|
@@ -3857,23 +4257,34 @@ async function shell(options) {
|
|
|
3857
4257
|
return manage(["shell"], options);
|
|
3858
4258
|
}
|
|
3859
4259
|
/**
|
|
3860
|
-
* The project and
|
|
4260
|
+
* The project and how to run a backup command: in the prod worker, on a
|
|
3861
4261
|
* server or the local run, or the `backup` service of projects from 0.1.
|
|
4262
|
+
* Without a running worker (tasks off, TASK_WORKERS=0) in a one-off worker
|
|
4263
|
+
* container, while the backend runs.
|
|
3862
4264
|
*/
|
|
3863
4265
|
function backupProject(options) {
|
|
3864
4266
|
const project = activeProject(open(options));
|
|
3865
4267
|
if (!project.prod) fail(`Backups run in the prod profile: on the server, or locally after ${command("dstack run")}.`);
|
|
3866
4268
|
const running = runningServices(project);
|
|
3867
4269
|
const service = ["backup", project.services.worker].find((name) => running.includes(name));
|
|
3868
|
-
if (
|
|
3869
|
-
return {
|
|
4270
|
+
if (service) return {
|
|
3870
4271
|
project,
|
|
3871
|
-
service
|
|
4272
|
+
run: ["exec", service]
|
|
3872
4273
|
};
|
|
4274
|
+
if (project.env.TASK_WORKERS === "0" && running.includes(project.services.backend)) return {
|
|
4275
|
+
project,
|
|
4276
|
+
run: [
|
|
4277
|
+
"run",
|
|
4278
|
+
"--rm",
|
|
4279
|
+
"--no-deps",
|
|
4280
|
+
project.services.worker
|
|
4281
|
+
]
|
|
4282
|
+
};
|
|
4283
|
+
fail(`The ${project.services.worker} service is not running. Start the project with ${command("dstack run")}.`);
|
|
3873
4284
|
}
|
|
3874
4285
|
/** `dstack backup`: back up the database and the media now, or list the backups. */
|
|
3875
4286
|
async function backup(options) {
|
|
3876
|
-
const { project,
|
|
4287
|
+
const { project, run } = backupProject(options);
|
|
3877
4288
|
const args = [
|
|
3878
4289
|
"python",
|
|
3879
4290
|
"manage.py",
|
|
@@ -3882,9 +4293,9 @@ async function backup(options) {
|
|
|
3882
4293
|
if (options.list) args.push("--list");
|
|
3883
4294
|
else if (!options.media) args.push("--no-media");
|
|
3884
4295
|
exitWith(compose(project, [
|
|
3885
|
-
|
|
4296
|
+
run[0],
|
|
3886
4297
|
"-T",
|
|
3887
|
-
|
|
4298
|
+
...run.slice(1),
|
|
3888
4299
|
...args
|
|
3889
4300
|
]));
|
|
3890
4301
|
}
|
|
@@ -3909,13 +4320,13 @@ function restoreArgs(name, options) {
|
|
|
3909
4320
|
}
|
|
3910
4321
|
/** `dstack restore <backup>`: restore a database dump, with --media its media too, or a media archive. */
|
|
3911
4322
|
async function restore(name, options) {
|
|
3912
|
-
const { project,
|
|
4323
|
+
const { project, run } = backupProject(options);
|
|
3913
4324
|
const args = restoreArgs(name, options);
|
|
3914
4325
|
if (!options.yes && !tty()) fail("Pass --yes to restore without a terminal.");
|
|
3915
4326
|
exitWith(compose(project, [
|
|
3916
|
-
|
|
4327
|
+
run[0],
|
|
3917
4328
|
...options.yes ? ["-T"] : [],
|
|
3918
|
-
|
|
4329
|
+
...run.slice(1),
|
|
3919
4330
|
...args
|
|
3920
4331
|
]));
|
|
3921
4332
|
}
|
|
@@ -4153,20 +4564,12 @@ async function config(options) {
|
|
|
4153
4564
|
intro("Configure");
|
|
4154
4565
|
const current = projectStack(project);
|
|
4155
4566
|
const next = { ...current };
|
|
4156
|
-
for (const
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
options: option.values.map((value) => ({
|
|
4163
|
-
value: value.value,
|
|
4164
|
-
label: value.label,
|
|
4165
|
-
hint: value.generated ? value.hint : "coming soon",
|
|
4166
|
-
disabled: !value.generated && value.value !== current[key]
|
|
4167
|
-
}))
|
|
4168
|
-
}));
|
|
4169
|
-
}
|
|
4567
|
+
for (const key of PROMPT_ORDER) if (options.fromCli(key)) next[key] = options[key];
|
|
4568
|
+
else if (interactive && SWITCHABLE.includes(key)) next[key] = unwrap(await p.select({
|
|
4569
|
+
message: stackOption(key).message,
|
|
4570
|
+
initialValue: current[key],
|
|
4571
|
+
options: choices(key, next)
|
|
4572
|
+
}));
|
|
4170
4573
|
if (next.analytics === "umami" && current.analytics !== "umami") {
|
|
4171
4574
|
const compose = join(project.directory, "compose.yaml");
|
|
4172
4575
|
if (!existsSync(compose) || !/^ {2}umami:/m.test(readFileSync(compose, "utf8"))) fail("compose.yaml has no umami service. Add it as the analytics docs describe, then run this again.");
|
|
@@ -4182,11 +4585,13 @@ async function config(options) {
|
|
|
4182
4585
|
p.outro("Nothing changed");
|
|
4183
4586
|
return;
|
|
4184
4587
|
}
|
|
4185
|
-
|
|
4588
|
+
const envText = readFileSync(envPath, "utf8");
|
|
4589
|
+
if (result.env !== envText) writeFileSync(envPath, result.env, { mode: 384 });
|
|
4186
4590
|
const manifestPath = join(project.directory, "package.json");
|
|
4187
4591
|
if (existsSync(manifestPath)) writeFileSync(manifestPath, setManifestPreset(readFileSync(manifestPath, "utf8"), encodePreset(next)));
|
|
4188
4592
|
for (const change of result.changes) p.log.success(change);
|
|
4189
|
-
|
|
4593
|
+
if (result.env === envText) p.outro(pc.green("Done"));
|
|
4594
|
+
else p.outro(`Restart to apply it: ${command("dstack stop")}, then ${command("dstack dev")}`);
|
|
4190
4595
|
}
|
|
4191
4596
|
// src/program.ts
|
|
4192
4597
|
/** Commands that print machine-readable output, so no update notice. */
|
|
@@ -4263,6 +4668,7 @@ function createProgram() {
|
|
|
4263
4668
|
}));
|
|
4264
4669
|
for (const flag of stackFlags()) presetCommand.addOption(flag);
|
|
4265
4670
|
program.command("production").description("Write .env.production for a server").argument("[directory]", "The project directory", ".").option("--domain <domain>", "The domain of the site, such as example.com").option("--email-url <url>", "The SMTP server, such as smtp+tls://user:pass@host:587").option("--force", "Overwrite an existing .env.production").option("-y, --yes", "Don't ask; fail when --domain is missing").action(production);
|
|
4671
|
+
program.command("settings").description("Ask for the missing production settings and credentials, and save them").addOption(dirOption()).option("--file <file>", "The file to change (default: .env.production, or .env on a server)").option("--set <NAME=value>", "Set a value without asking, such as --set ADMIN_EMAIL=you@example.com", (value, previous = []) => [...previous, value]).option("--all", "Ask for every setting, also the ones that are set").option("-y, --yes", "Don't ask; fail when a setting is still missing").action(settings);
|
|
4266
4672
|
program.command("doctor").description("Check a project and its services").argument("[directory]", "The project directory", ".").action(doctor);
|
|
4267
4673
|
program.command("secret").description("Print a random secret").argument("[bytes]", "The number of random bytes", positiveInteger, 32).action((bytes) => console.log(generateSecret(bytes)));
|
|
4268
4674
|
tab(program, { completionCommandName: "completion" });
|