@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 CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { C as stackOption, D as checkTools, E as runDoctor, O as parseEnv, S as resolvePreset, T as generateSecret, _ as STACK_OPTIONS, a as lockBackend, b as encodePreset, c as scaffold, d as umamiEnv, f as validateProjectName, g as NAMED_PRESETS, h as DEFAULT_STACK, i as LOCAL_S3, k as setEnvValues, l as timeZones, m as CLI_VERSION, n as validateDomain, p as validateTimeZone, s as relativeDirectory, t as productionEnv$1, u as toProjectName, v as applies, w as generateProjectSecrets, x as pendingChoices, y as decodePreset } from "./production-DIj9yRcg.js";
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 analytics = project.profiles.includes("analytics") ? "umami" : "off";
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
- analytics
2585
+ ...fromEnv
2582
2586
  };
2583
2587
  } catch {}
2584
2588
  return {
2585
2589
  ...DEFAULT_STACK,
2586
- analytics,
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: "http://localhost:3000"
2629
+ url: site
2606
2630
  },
2607
2631
  {
2608
2632
  label: "Admin",
2609
- url: "http://localhost:3000/admin/"
2633
+ url: `${site}/admin/`
2610
2634
  },
2611
2635
  {
2612
2636
  label: "Mail",
2613
- url: "http://localhost:8025"
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: "http://localhost:8333"
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
- /** The prompt choices of an option; values this version does not generate yet are shown but disabled. */
3426
- function choices(key) {
3427
- return stackOption(key).values.map((value, index) => ({
3428
- value: value.value,
3429
- label: value.label,
3430
- hint: value.generated || index === 0 ? value.hint : "coming soon",
3431
- disabled: !value.generated && index > 0
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 option of STACK_OPTIONS) {
3449
- const key = option.key;
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 stack[key] = unwrap(await p.select({
3460
- message: option.message,
3461
- options: choices(key),
3462
- initialValue: stack[key]
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("docker compose up"),
3651
+ command(startCommand),
3609
3652
  "",
3610
- `${pc.bold("Site")} ${url("http://localhost:3000")}`,
3611
- `${pc.bold("Admin")} ${url("http://localhost:3000/admin/")}`,
3612
- `${pc.bold("Mail")} ${url("http://localhost:8025")}`,
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("docker compose up")}.`));
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 = STACK_OPTIONS.filter((option) => applies(option.key, stack)).map((option) => {
3641
- const value = option.values.find((candidate) => candidate.value === stack[option.key]);
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
- if (result.todo.length) p.note(result.todo.map((item) => `${pc.yellow("•")} ${item}`).join("\n"), "Before the first deploy");
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 = ["storage", "analytics"];
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" ? LOCAL_S3 : EMPTY_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
- const project = open(options);
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 the service that run the backups: the prod worker, on a
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 (!service) fail(`The ${project.services.worker} service is not running. Start the project with ${command("dstack run")}.`);
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, service } = backupProject(options);
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
- "exec",
4296
+ run[0],
3886
4297
  "-T",
3887
- service,
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, service } = backupProject(options);
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
- "exec",
4327
+ run[0],
3917
4328
  ...options.yes ? ["-T"] : [],
3918
- service,
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 option of STACK_OPTIONS) {
4157
- const key = option.key;
4158
- if (options.fromCli(key)) next[key] = options[key];
4159
- else if (interactive && SWITCHABLE.includes(key)) next[key] = unwrap(await p.select({
4160
- message: option.message,
4161
- initialValue: current[key],
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
- writeFileSync(envPath, result.env, { mode: 384 });
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
- p.outro(`Restart to apply it: ${command("dstack stop")}, then ${command("dstack dev")}`);
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" });