@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/dist/bin.js CHANGED
@@ -1,9 +1,10 @@
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 { $ as isDockerDown, A as projectUrls, B as encodePreset, C as RUN_ENV, D as findProjectDirectory, E as composeOutput, F as NAMED_PRESETS, G as chooseFreePorts, H as resolvePreset, I as PROMPT_ORDER, J as portValues, K as describeMove, L as STACK_OPTIONS, M as readProject, N as runningServices, O as parseProfiles, P as DEFAULT_STACK, Q as dockerRunning, R as applies, S as RUN_DOMAIN, T as compose, U as stackOption, V as pendingChoices, W as DEV_PORTS, X as checkTools, Y as runDoctor, Z as dockerNotRunning, _ as validateTimeZone, a as LOCAL_S3, b as generateProjectSecrets, c as lockBackend, d as scaffold, et as parseEnv, f as timeZones, g as validateProjectName, h as usesValkey, j as readLocalRun, k as projectStack, m as umamiEnv, n as productionEnv$1, nt as setEnvValues, o as cacheEnv, p as toProjectName, q as envPorts, r as validateDomain, s as localS3, t as missingS3, tt as quoteEnvValue, u as relativeDirectory, v as CLI_VERSION, w as activeProject, x as generateSecret, y as PACKAGE_NAME } from "./production-CGkI7oNe.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
+ import { fileURLToPath } from "node:url";
7
8
  import pc from "picocolors";
8
9
  import tab from "@bomb.sh/tab/commander";
9
10
  import * as p from "@clack/prompts";
@@ -136,7 +137,7 @@ function probeFor(definition, ports) {
136
137
  }
137
138
  /** Plans the Bunny app for a resolved Compose project. */
138
139
  function planApp(project, options) {
139
- const entries = Object.entries(project.services).sort(([a], [b]) => a.localeCompare(b));
140
+ const entries = Object.entries(project.services).filter(([, definition]) => definition.deploy?.replicas !== 0).sort(([a], [b]) => a.localeCompare(b));
140
141
  if (!entries.length) throw new Error("compose.yaml has no services for production.");
141
142
  const names = /* @__PURE__ */ new Map();
142
143
  for (const [service, definition] of entries) names.set(service, hostNames(service, definition));
@@ -1126,6 +1127,7 @@ var BunnyClient = class {
1126
1127
  async docker(args, options = {}) {
1127
1128
  const result = await this.run("docker", args, options);
1128
1129
  if (result.code === 0) return result.stdout;
1130
+ if (isDockerDown(`${result.stderr}\n${result.stdout}`)) throw new Error(dockerNotRunning());
1129
1131
  const last = (result.stderr.trim() || result.stdout.trim()).split("\n").at(-1) ?? "";
1130
1132
  throw new Error(`docker ${args[0]} failed (exit ${result.code})${last ? `: ${last}` : "."}`);
1131
1133
  }
@@ -2494,205 +2496,6 @@ function getProvider(id) {
2494
2496
  if (!provider) throw new Error(`Unknown deploy provider ${id}. Choose one of: ${PROVIDERS.map((p) => p.id).join(", ")}.`);
2495
2497
  return provider;
2496
2498
  }
2497
- // src/project.ts
2498
- /** The `.env` of `dstack run` on a development machine: the prod profile at https://site.localhost. */
2499
- const RUN_ENV = ".env.run";
2500
- const RUN_DOMAIN = "site.localhost";
2501
- /** The project directory: `start` or the nearest parent with `compose.yaml` and `.env`. */
2502
- function findProjectDirectory(start) {
2503
- let directory = resolve(start);
2504
- for (;;) {
2505
- if (existsSync(join(directory, "compose.yaml")) && existsSync(join(directory, ".env"))) return directory;
2506
- const parent = dirname(directory);
2507
- if (parent === directory) return void 0;
2508
- directory = parent;
2509
- }
2510
- }
2511
- function parseProfiles(value) {
2512
- return (value ?? "").split(",").map((profile) => profile.trim()).filter(Boolean);
2513
- }
2514
- function projectName(directory) {
2515
- try {
2516
- const match = readFileSync(join(directory, "compose.yaml"), "utf8").match(/^name:\s*["']?([\w.-]+)/m);
2517
- if (match?.[1]) return match[1];
2518
- } catch {}
2519
- return basename(directory).toLowerCase();
2520
- }
2521
- function fromEnv(directory, name, envFile, localRun) {
2522
- const env = parseEnv(readFileSync(join(directory, envFile), "utf8"));
2523
- const profiles = parseProfiles(env.COMPOSE_PROFILES);
2524
- const prod = profiles.includes("prod");
2525
- const suffix = prod ? "-prod" : "";
2526
- return {
2527
- directory,
2528
- name: localRun ? `${name}-run` : name,
2529
- env,
2530
- profiles,
2531
- prod,
2532
- localRun,
2533
- services: {
2534
- backend: `backend${suffix}`,
2535
- frontend: `frontend${suffix}`,
2536
- worker: `worker${suffix}`
2537
- },
2538
- composeArgs: localRun ? [
2539
- "--env-file",
2540
- envFile,
2541
- "--project-name",
2542
- `${name}-run`
2543
- ] : [],
2544
- composeEnv: localRun ? {
2545
- ENV_FILE: envFile,
2546
- COMPOSE_PROFILES: env.COMPOSE_PROFILES ?? ""
2547
- } : {}
2548
- };
2549
- }
2550
- /** Reads the project in or above `start`. Throws with a one-line reason when there is none. */
2551
- function readProject(start = ".") {
2552
- const directory = findProjectDirectory(start);
2553
- if (!directory) throw new Error(`No DjangoStack project in ${resolve(start)}: run this where compose.yaml and .env are.`);
2554
- return fromEnv(directory, projectName(directory), ".env", false);
2555
- }
2556
- /** The local production run of a development project, when `.env.run` exists. */
2557
- function readLocalRun(project) {
2558
- if (project.prod || project.localRun || !existsSync(join(project.directory, ".env.run"))) return void 0;
2559
- return fromEnv(project.directory, projectName(project.directory), RUN_ENV, true);
2560
- }
2561
- /** The `django-stack` section of the project's `package.json`. */
2562
- function projectManifest(project) {
2563
- try {
2564
- return JSON.parse(readFileSync(join(project.directory, "package.json"), "utf8"))["django-stack"] ?? {};
2565
- } catch {
2566
- return {};
2567
- }
2568
- }
2569
- /**
2570
- * The project's stack: from the preset `init` saved in `package.json`, or,
2571
- * for projects from before that, the defaults with the storage from `.env`.
2572
- * Analytics follow the `analytics` profile: projects from before Umami was
2573
- * generated have the default in their preset without running it.
2574
- */
2575
- function projectStack(project) {
2576
- const analytics = project.profiles.includes("analytics") ? "umami" : "off";
2577
- const { preset } = projectManifest(project);
2578
- if (preset) try {
2579
- return {
2580
- ...decodePreset(preset),
2581
- analytics
2582
- };
2583
- } catch {}
2584
- return {
2585
- ...DEFAULT_STACK,
2586
- analytics,
2587
- storage: project.profiles.includes("s3") ? "s3" : "local"
2588
- };
2589
- }
2590
- /** The URLs of a running project. */
2591
- function projectUrls(project) {
2592
- if (project.prod) {
2593
- const site = (project.env.SITE_URL ?? "").replace(/\/$/, "");
2594
- return [{
2595
- label: "Site",
2596
- url: `${site}/`
2597
- }, {
2598
- label: "Admin",
2599
- url: `${site}/admin/`
2600
- }];
2601
- }
2602
- const urls = [
2603
- {
2604
- label: "Site",
2605
- url: "http://localhost:3000"
2606
- },
2607
- {
2608
- label: "Admin",
2609
- url: "http://localhost:3000/admin/"
2610
- },
2611
- {
2612
- label: "Mail",
2613
- url: "http://localhost:8025"
2614
- }
2615
- ];
2616
- if (project.profiles.includes("s3")) urls.push({
2617
- label: "S3",
2618
- url: "http://localhost:8333"
2619
- });
2620
- return urls;
2621
- }
2622
- /** Runs a command with the terminal attached and returns its exit status. */
2623
- const runInherit = (cmd, args, options) => {
2624
- const result = spawnSync(cmd, args, {
2625
- stdio: "inherit",
2626
- ...options
2627
- });
2628
- if (result.error) return 127;
2629
- return result.status ?? 1;
2630
- };
2631
- /** The full `docker compose` argument list for a project. */
2632
- function composeCommand(project, args) {
2633
- return [
2634
- "compose",
2635
- ...project.composeArgs,
2636
- ...args
2637
- ];
2638
- }
2639
- function composeOptions(project) {
2640
- return {
2641
- cwd: project.directory,
2642
- env: {
2643
- ...process.env,
2644
- ...project.composeEnv
2645
- }
2646
- };
2647
- }
2648
- /** Runs `docker compose <args>` for the project with the terminal attached. */
2649
- function compose(project, args, run = runInherit) {
2650
- return run("docker", composeCommand(project, args), composeOptions(project));
2651
- }
2652
- /** Runs `docker compose <args>` and captures the output. */
2653
- function composeOutput(project, args) {
2654
- const result = spawnSync("docker", composeCommand(project, args), {
2655
- ...composeOptions(project),
2656
- encoding: "utf8",
2657
- stdio: [
2658
- "ignore",
2659
- "pipe",
2660
- "pipe"
2661
- ]
2662
- });
2663
- return {
2664
- status: result.status ?? 1,
2665
- output: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim()
2666
- };
2667
- }
2668
- /** The running services of the project. */
2669
- function runningServices(project) {
2670
- const result = spawnSync("docker", composeCommand(project, [
2671
- "ps",
2672
- "--services",
2673
- "--filter",
2674
- "status=running"
2675
- ]), {
2676
- ...composeOptions(project),
2677
- encoding: "utf8",
2678
- stdio: [
2679
- "ignore",
2680
- "pipe",
2681
- "ignore"
2682
- ]
2683
- });
2684
- return result.status === 0 ? result.stdout.split("\n").filter(Boolean) : [];
2685
- }
2686
- /**
2687
- * The project a command acts on: the local production run when it is the
2688
- * one running, otherwise the project `.env` describes.
2689
- */
2690
- function activeProject(project) {
2691
- const localRun = readLocalRun(project);
2692
- if (!localRun) return project;
2693
- if (runningServices(project).length) return project;
2694
- return runningServices(localRun).length ? localRun : project;
2695
- }
2696
2499
  // src/ui.ts
2697
2500
  /** The DjangoStack badge with the version and an optional title. */
2698
2501
  function intro(title) {
@@ -3171,90 +2974,6 @@ async function doctor(directoryArgument) {
3171
2974
  }
3172
2975
  p.outro(pc.green("All checks passed"));
3173
2976
  }
3174
- // src/update-check.ts
3175
- const PACKAGE_NAME = "@django-stack/cli";
3176
- const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
3177
- const DAY = 864e5;
3178
- /** `$XDG_CACHE_HOME/django-stack/update-check.json`, or under `~/.cache`. */
3179
- function defaultCachePath(env = process.env) {
3180
- const base = env.XDG_CACHE_HOME || process.platform === "win32" && env.LOCALAPPDATA || join(homedir(), ".cache");
3181
- return join(base, "django-stack", "update-check.json");
3182
- }
3183
- /** Whether to check at all: never in CI, without a terminal, or when turned off. */
3184
- function updateCheckEnabled(env = process.env, isTTY = Boolean(process.stdout.isTTY)) {
3185
- if (!isTTY) return false;
3186
- if (env.CI && env.CI !== "0" && env.CI !== "false") return false;
3187
- for (const key of ["DJANGO_STACK_NO_UPDATE_CHECK", "NO_UPDATE_NOTIFIER"]) if (env[key] && env[key] !== "0") return false;
3188
- return true;
3189
- }
3190
- function parse(version) {
3191
- const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(version.trim());
3192
- if (!match) return void 0;
3193
- return [
3194
- Number(match[1]),
3195
- Number(match[2]),
3196
- Number(match[3]),
3197
- match[4] ?? ""
3198
- ];
3199
- }
3200
- /** Whether `latest` is a newer version than `current`. Prereleases of `latest` never count. */
3201
- function isNewer(latest, current) {
3202
- const a = parse(latest);
3203
- const b = parse(current);
3204
- if (!a || !b || a[3]) return false;
3205
- for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
3206
- return b[3] !== "";
3207
- }
3208
- function readCache(path) {
3209
- try {
3210
- const data = JSON.parse(readFileSync(path, "utf8"));
3211
- if (typeof data.checkedAt === "number" && typeof data.latest === "string") return data;
3212
- } catch {}
3213
- }
3214
- function writeCache(path, cache) {
3215
- try {
3216
- mkdirSync(dirname(path), { recursive: true });
3217
- writeFileSync(path, JSON.stringify(cache));
3218
- } catch {}
3219
- }
3220
- /**
3221
- * The latest version on npm when it is newer than `current`, otherwise
3222
- * undefined. Uses a cached result for a day; never throws.
3223
- */
3224
- async function checkForUpdate(options) {
3225
- const cachePath = options.cachePath ?? defaultCachePath();
3226
- const now = options.now ?? Date.now;
3227
- const maxAge = options.maxAge ?? DAY;
3228
- let latest = readCache(cachePath);
3229
- if (!latest || now() - latest.checkedAt > maxAge || latest.checkedAt > now()) try {
3230
- const response = await (options.fetch ?? fetch)(REGISTRY_URL, {
3231
- headers: { accept: "application/json" },
3232
- signal: AbortSignal.timeout(options.timeout ?? 1500)
3233
- });
3234
- if (!response.ok) return void 0;
3235
- const { version } = await response.json();
3236
- if (typeof version !== "string") return void 0;
3237
- latest = {
3238
- checkedAt: now(),
3239
- latest: version
3240
- };
3241
- writeCache(cachePath, latest);
3242
- } catch {
3243
- return;
3244
- }
3245
- return isNewer(latest.latest, options.current) ? latest.latest : void 0;
3246
- }
3247
- /** The command that runs or installs the latest version, depending on how this one was started. */
3248
- function updateCommand(location = import.meta.url, env = process.env) {
3249
- const path = decodeURIComponent(location).replaceAll("\\", "/");
3250
- if (path.includes("/_npx/")) return `npx ${PACKAGE_NAME}@latest`;
3251
- if (path.includes("/dlx/") || path.includes("/dlx-")) return `pnpm dlx ${PACKAGE_NAME}@latest`;
3252
- if (path.includes("/bunx-")) return `bunx ${PACKAGE_NAME}@latest`;
3253
- if (path.includes("/pnpm/global/")) return `pnpm add -g ${PACKAGE_NAME}@latest`;
3254
- if (path.includes("/.bun/install/global/")) return `bun add -g ${PACKAGE_NAME}@latest`;
3255
- if (env.npm_config_user_agent?.startsWith("pnpm")) return `pnpm add -g ${PACKAGE_NAME}@latest`;
3256
- return `npm i -g ${PACKAGE_NAME}@latest`;
3257
- }
3258
2977
  // src/global-install.ts
3259
2978
  /** The name of the installed command. */
3260
2979
  const BIN = "dstack";
@@ -3273,6 +2992,37 @@ function temporaryRunner(location = import.meta.url) {
3273
2992
  if (path.includes("/dlx/") || path.includes("/dlx-")) return "pnpm";
3274
2993
  if (path.includes("/bunx-")) return "bun";
3275
2994
  }
2995
+ /** How this copy runs, and with which package manager, from where its files are. */
2996
+ function installation(location = import.meta.url, env = process.env) {
2997
+ const temporary = temporaryRunner(location);
2998
+ if (temporary) return {
2999
+ kind: "temporary",
3000
+ runner: temporary
3001
+ };
3002
+ const path = normalize(location);
3003
+ if (!path.includes("/node_modules/")) return { kind: "checkout" };
3004
+ const lower = path.toLowerCase();
3005
+ if (lower.includes("/pnpm/global/")) return {
3006
+ kind: "installed",
3007
+ runner: "pnpm"
3008
+ };
3009
+ if (lower.includes("/.bun/install/global/")) return {
3010
+ kind: "installed",
3011
+ runner: "bun"
3012
+ };
3013
+ if (lower.includes("/yarn/global/") || lower.includes("/yarn/data/global/")) return {
3014
+ kind: "installed",
3015
+ runner: "yarn"
3016
+ };
3017
+ if (env.npm_config_user_agent?.startsWith("pnpm")) return {
3018
+ kind: "installed",
3019
+ runner: "pnpm"
3020
+ };
3021
+ return {
3022
+ kind: "installed",
3023
+ runner: "npm"
3024
+ };
3025
+ }
3276
3026
  /** Whether `dstack` is on the `PATH`, leaving out the temporary directories npx and friends add. */
3277
3027
  function isOnPath(env = process.env, platform = process.platform) {
3278
3028
  const names = platform === "win32" ? [
@@ -3297,6 +3047,12 @@ function installCommand(runner = "npm") {
3297
3047
  "--global",
3298
3048
  `${PACKAGE_NAME}@latest`
3299
3049
  ];
3050
+ if (runner === "yarn") return [
3051
+ "yarn",
3052
+ "global",
3053
+ "add",
3054
+ `${PACKAGE_NAME}@latest`
3055
+ ];
3300
3056
  return [
3301
3057
  "npm",
3302
3058
  "i",
@@ -3351,7 +3107,7 @@ function explainFailure(runner, output) {
3351
3107
  const last = output.trim().split("\n").pop()?.trim();
3352
3108
  return `${last ? `${last} ` : ""}Run ${manual} to try again.`;
3353
3109
  }
3354
- function run$1(args) {
3110
+ function runInstall(args) {
3355
3111
  return new Promise((resolve) => {
3356
3112
  const [cmd, ...rest] = args;
3357
3113
  let output = "";
@@ -3409,7 +3165,7 @@ async function offerGlobalInstall() {
3409
3165
  }
3410
3166
  const spinner = p.spinner();
3411
3167
  spinner.start(`Installing with ${command(args.join(" "))}`);
3412
- const result = await run$1(args);
3168
+ const result = await runInstall(args);
3413
3169
  if (!result.ok) {
3414
3170
  saveInstallAnswer("failed");
3415
3171
  spinner.error(`Couldn't install ${BIN}`);
@@ -3422,14 +3178,23 @@ async function offerGlobalInstall() {
3422
3178
  return true;
3423
3179
  }
3424
3180
  // 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
- }));
3181
+ /**
3182
+ * The prompt choices of an option; values this version does not generate yet
3183
+ * are shown but disabled. The tasks follow the cache chosen before them: Huey
3184
+ * keeps its queue in the database, or in Valkey with the Valkey cache.
3185
+ */
3186
+ function choices(key, stack = {}) {
3187
+ return stackOption(key).values.flatMap((value, index) => {
3188
+ let hint = value.generated || index === 0 ? value.hint : "coming soon";
3189
+ if (key === "tasks" && value.value === "huey") hint = stack.cache === "valkey" ? "a worker container, the queue in Valkey" : "a worker container, the queue in Postgres";
3190
+ if (key === "tasks" && value.value === "huey-valkey" && stack.cache === "valkey") return [];
3191
+ return [{
3192
+ value: value.value,
3193
+ label: value.label,
3194
+ hint,
3195
+ disabled: !value.generated && index > 0
3196
+ }];
3197
+ });
3433
3198
  }
3434
3199
  /**
3435
3200
  * The stack from, in order of precedence, the flags, the preset, the prompts
@@ -3445,8 +3210,8 @@ async function chooseStack(options, interactive, askDatabase) {
3445
3210
  }
3446
3211
  const stack = { ...DEFAULT_STACK };
3447
3212
  let databaseUrl;
3448
- for (const option of STACK_OPTIONS) {
3449
- const key = option.key;
3213
+ for (const key of PROMPT_ORDER) {
3214
+ const option = stackOption(key);
3450
3215
  if (options.fromCli(key)) stack[key] = options[key];
3451
3216
  else if (key === "storage" && options.fromCli("localS3") && options.localS3) stack[key] = "s3";
3452
3217
  else if (preset) stack[key] = preset[key];
@@ -3456,11 +3221,14 @@ async function chooseStack(options, interactive, askDatabase) {
3456
3221
  if ("databaseUrl" in answer) databaseUrl = answer.databaseUrl;
3457
3222
  else stack[key] = answer.postgres;
3458
3223
  } 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
- }));
3224
+ else {
3225
+ const initialValue = key === "storage" && stack.media === "s3" ? "s3" : stack[key];
3226
+ stack[key] = unwrap(await p.select({
3227
+ message: option.message,
3228
+ options: choices(key, stack),
3229
+ initialValue
3230
+ }));
3231
+ }
3464
3232
  }
3465
3233
  return {
3466
3234
  stack,
@@ -3555,6 +3323,8 @@ async function init(directoryArgument, options) {
3555
3323
  message: "Add demo content? (example pages and posts)",
3556
3324
  initialValue: true
3557
3325
  }));
3326
+ const ports = await chooseFreePorts({}, stack.storage === "s3" ? ["s3"] : []);
3327
+ for (const move of ports.moves) p.log.info(describeMove(move));
3558
3328
  const spinner = p.spinner();
3559
3329
  spinner.start("Creating the project");
3560
3330
  let result;
@@ -3567,8 +3337,11 @@ async function init(directoryArgument, options) {
3567
3337
  databaseUrl,
3568
3338
  localS3: stack.storage === "s3",
3569
3339
  analytics: stack.analytics === "umami",
3340
+ cache: stack.cache,
3341
+ tasks: stack.tasks,
3570
3342
  preset: encodePreset(stack),
3571
3343
  timeZone,
3344
+ ports: ports.ports,
3572
3345
  link: options.link
3573
3346
  });
3574
3347
  } catch (error) {
@@ -3603,22 +3376,24 @@ async function init(directoryArgument, options) {
3603
3376
  } catch {
3604
3377
  p.log.warn("Could not commit the project with git. Commit it yourself.");
3605
3378
  }
3379
+ if (options.installPrompt !== false && !options.yes) await offerGlobalInstall();
3380
+ const startCommand = isOnPath() ? "dstack dev" : "docker compose up";
3606
3381
  p.note([
3607
3382
  command(`cd ${relativeDirectory(target)}`),
3608
- command("docker compose up"),
3383
+ command(startCommand),
3609
3384
  "",
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")}`,
3385
+ `${pc.bold("Site")} ${url(`http://localhost:${ports.ports.SITE_PORT}`)}`,
3386
+ `${pc.bold("Admin")} ${url(`http://localhost:${ports.ports.SITE_PORT}/admin/`)}`,
3387
+ `${pc.bold("Mail")} ${url(`http://localhost:${ports.ports.MAIL_PORT}`)}`,
3613
3388
  "",
3614
3389
  `Admin user ${secret(result.adminUsername)}, password ${secret(result.adminPassword)}`,
3615
3390
  pc.dim("The password is in .env as ADMIN_PASSWORD. Change it after you sign in.")
3616
3391
  ].join("\n"), "Next steps");
3617
3392
  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(", ")}.`);
3393
+ 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
3394
  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")}.`));
3395
+ 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
3396
  else if (options.start) {
3621
- if (options.installPrompt !== false && !options.yes) await offerGlobalInstall();
3622
3397
  p.outro(`Starting the project with ${command("docker compose up")}`);
3623
3398
  execFileSync("docker", ["compose", "up"], {
3624
3399
  cwd: target,
@@ -3637,8 +3412,9 @@ async function preset(code, options) {
3637
3412
  } catch (error) {
3638
3413
  fail(error.message);
3639
3414
  }
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]);
3415
+ const lines = PROMPT_ORDER.filter((key) => applies(key, stack)).map((key) => {
3416
+ const option = stackOption(key);
3417
+ const value = option.values.find((candidate) => candidate.value === stack[key]);
3642
3418
  return `${pc.bold(option.message.padEnd(24))}${value?.label ?? ""}`;
3643
3419
  });
3644
3420
  p.note(lines.join("\n"), command(`--preset ${encodePreset(stack)}`));
@@ -3649,6 +3425,295 @@ async function preset(code, options) {
3649
3425
  const encoded = encodePreset(stack);
3650
3426
  p.outro(`Create this stack with ${command(`${selfCommand()} init --preset ${encoded}`)}`);
3651
3427
  }
3428
+ // src/settings.ts
3429
+ function validateEmail(value) {
3430
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? void 0 : "Enter an email address, such as you@example.com.";
3431
+ }
3432
+ function validateHost(value) {
3433
+ return /^[a-z0-9.-]+$/i.test(value) && value.includes(".") ? void 0 : "Enter a host name, such as smtp.example.com.";
3434
+ }
3435
+ function validatePort(value) {
3436
+ const port = Number(value);
3437
+ return Number.isInteger(port) && port > 0 && port < 65536 ? void 0 : "Enter a port, such as 587.";
3438
+ }
3439
+ function validateUrl(value) {
3440
+ if (!value) return void 0;
3441
+ return /^https?:\/\/[^\s/]+/.test(value) ? void 0 : "Enter a URL that starts with https://.";
3442
+ }
3443
+ const required = (label) => (value) => value.trim() ? void 0 : `Enter the ${label}.`;
3444
+ /**
3445
+ * An SMTP URL for EMAIL_URL: smtps:// on port 465, smtp:// on 25, otherwise
3446
+ * smtp+tls:// (STARTTLS), with the user name and password URL-encoded.
3447
+ */
3448
+ function smtpUrl(parts) {
3449
+ const port = Number(parts.port || "587");
3450
+ const scheme = port === 465 ? "smtps" : port === 25 ? "smtp" : "smtp+tls";
3451
+ const user = parts.username ? encodeURIComponent(parts.username) : "";
3452
+ const password = parts.password ? `:${encodeURIComponent(parts.password)}` : "";
3453
+ return `${scheme}://${user ? `${user}${password}@` : ""}${parts.host}:${port}`;
3454
+ }
3455
+ /** The parts of an SMTP URL, for the current answers. */
3456
+ function smtpParts(value) {
3457
+ try {
3458
+ const url = new URL(value ?? "");
3459
+ return {
3460
+ host: url.hostname,
3461
+ port: url.port,
3462
+ username: decodeURIComponent(url.username),
3463
+ password: decodeURIComponent(url.password)
3464
+ };
3465
+ } catch {
3466
+ return {};
3467
+ }
3468
+ }
3469
+ const placeholderMail = (value) => !value || value.includes("mailpit") || value.includes("example.com") || value.startsWith("console:");
3470
+ const SETTING_GROUPS = [
3471
+ {
3472
+ title: "Admin email",
3473
+ names: ["ADMIN_EMAIL"],
3474
+ applies: () => true,
3475
+ missing: (env) => !env.ADMIN_EMAIL || env.ADMIN_EMAIL.endsWith("@example.com") ? ["ADMIN_EMAIL"] : [],
3476
+ fields: [{
3477
+ name: "ADMIN_EMAIL",
3478
+ message: "Your email address, for the admin user",
3479
+ placeholder: "you@example.com",
3480
+ validate: validateEmail
3481
+ }],
3482
+ values: (answers) => ({ ADMIN_EMAIL: answers.ADMIN_EMAIL ?? "" })
3483
+ },
3484
+ {
3485
+ title: "Outgoing mail",
3486
+ names: ["EMAIL_URL"],
3487
+ applies: () => true,
3488
+ missing: (env) => placeholderMail(env.EMAIL_URL) ? ["EMAIL_URL"] : [],
3489
+ fields: [
3490
+ {
3491
+ name: "host",
3492
+ message: "SMTP server",
3493
+ placeholder: "smtp.example.com",
3494
+ validate: validateHost
3495
+ },
3496
+ {
3497
+ name: "port",
3498
+ message: "SMTP port",
3499
+ defaultValue: "587",
3500
+ validate: validatePort
3501
+ },
3502
+ {
3503
+ name: "username",
3504
+ message: "SMTP user name",
3505
+ optional: true
3506
+ },
3507
+ {
3508
+ name: "password",
3509
+ message: "SMTP password",
3510
+ secret: true,
3511
+ optional: true
3512
+ }
3513
+ ],
3514
+ current: (env, field) => placeholderMail(env.EMAIL_URL) ? void 0 : smtpParts(env.EMAIL_URL)[field],
3515
+ values: (answers) => ({ EMAIL_URL: smtpUrl({
3516
+ host: answers.host ?? "",
3517
+ port: answers.port ?? "587",
3518
+ username: answers.username,
3519
+ password: answers.password
3520
+ }) })
3521
+ },
3522
+ {
3523
+ title: "S3 for uploads",
3524
+ names: [
3525
+ "S3_BUCKET",
3526
+ "S3_ENDPOINT_URL",
3527
+ "S3_REGION",
3528
+ "S3_ACCESS_KEY_ID",
3529
+ "S3_SECRET_ACCESS_KEY",
3530
+ "S3_PUBLIC_URL"
3531
+ ],
3532
+ applies: (env, context) => context.media === "s3" || Boolean(env.S3_BUCKET),
3533
+ missing: missingS3,
3534
+ fields: [
3535
+ {
3536
+ name: "S3_BUCKET",
3537
+ message: "S3 bucket",
3538
+ placeholder: "example-media",
3539
+ validate: required("bucket")
3540
+ },
3541
+ {
3542
+ name: "S3_ENDPOINT_URL",
3543
+ message: "S3 endpoint, empty for AWS",
3544
+ placeholder: "https://fsn1.your-objectstorage.com",
3545
+ optional: true,
3546
+ validate: validateUrl
3547
+ },
3548
+ {
3549
+ name: "S3_REGION",
3550
+ message: "S3 region",
3551
+ placeholder: "eu-central-1",
3552
+ validate: required("region")
3553
+ },
3554
+ {
3555
+ name: "S3_ACCESS_KEY_ID",
3556
+ message: "S3 access key ID",
3557
+ validate: required("access key ID")
3558
+ },
3559
+ {
3560
+ name: "S3_SECRET_ACCESS_KEY",
3561
+ message: "S3 secret access key",
3562
+ secret: true,
3563
+ validate: required("secret access key")
3564
+ },
3565
+ {
3566
+ name: "S3_PUBLIC_URL",
3567
+ message: "Public URL of the files, empty when it is the endpoint",
3568
+ placeholder: "https://media.example.com",
3569
+ optional: true,
3570
+ validate: validateUrl
3571
+ }
3572
+ ],
3573
+ current: (env, field) => env[field],
3574
+ values: (answers) => Object.fromEntries([
3575
+ "S3_BUCKET",
3576
+ "S3_ENDPOINT_URL",
3577
+ "S3_REGION",
3578
+ "S3_ACCESS_KEY_ID",
3579
+ "S3_SECRET_ACCESS_KEY",
3580
+ "S3_PUBLIC_URL"
3581
+ ].map((name) => [name, answers[name] ?? ""]))
3582
+ }
3583
+ ];
3584
+ /** The groups of a production `.env` to ask for: the missing ones, or with `all` every one that applies. */
3585
+ function settingsToAsk(env, context, all = false) {
3586
+ return SETTING_GROUPS.filter((group) => group.applies(env, context) && (all || group.missing(env).length));
3587
+ }
3588
+ /** Every `.env` variable `--set` takes. */
3589
+ const SETTING_NAMES = SETTING_GROUPS.flatMap((group) => group.names);
3590
+ /** Parses `--set NAME=value` arguments. Throws with a one-line reason. */
3591
+ function parseAssignments(items) {
3592
+ const values = {};
3593
+ for (const item of items) {
3594
+ const index = item.indexOf("=");
3595
+ const name = index > 0 ? item.slice(0, index) : "";
3596
+ if (!SETTING_NAMES.includes(name)) throw new Error(`${JSON.stringify(item)} is not NAME=value for one of ${SETTING_NAMES.join(", ")}.`);
3597
+ const value = item.slice(index + 1);
3598
+ if (/[\r\n]/.test(value)) throw new Error(`The value of ${name} has a line break.`);
3599
+ if (name === "ADMIN_EMAIL" && validateEmail(value)) throw new Error(`ADMIN_EMAIL: ${validateEmail(value)}`);
3600
+ values[name] = value;
3601
+ }
3602
+ return values;
3603
+ }
3604
+ /** The items of `production`'s to-do list that the settings now in `env` have done. */
3605
+ function remainingTodo(todo, env) {
3606
+ return todo.filter((item) => {
3607
+ const group = SETTING_GROUPS.find((candidate) => candidate.names.some((name) => item.includes(name.startsWith("S3_") ? "S3_" : name)));
3608
+ return !group || group.missing(env).length > 0;
3609
+ });
3610
+ }
3611
+ // src/commands/settings.ts
3612
+ /** The `media` stack option of the project, from the preset in package.json. */
3613
+ function projectContext(directory) {
3614
+ try {
3615
+ return { media: projectStack(readProject(directory)).media };
3616
+ } catch {
3617
+ return {};
3618
+ }
3619
+ }
3620
+ /** Asks for each field of the groups; returns the `.env` values. */
3621
+ async function askSettings(groups, env) {
3622
+ const values = {};
3623
+ for (const group of groups) {
3624
+ p.log.step(pc.bold(group.title));
3625
+ const answers = {};
3626
+ for (const field of group.fields) {
3627
+ const current = group.current?.(env, field.name);
3628
+ const check = (value) => {
3629
+ const text = (value ?? "").trim();
3630
+ if (!text && (field.optional || field.defaultValue || field.secret && current)) return void 0;
3631
+ return field.validate?.(text) ?? (text ? void 0 : `Enter the ${field.message.toLowerCase()}.`);
3632
+ };
3633
+ let answer;
3634
+ if (field.secret) {
3635
+ answer = unwrap(await p.password({
3636
+ message: current ? `${field.message} (empty keeps the current one)` : field.message,
3637
+ validate: check
3638
+ }));
3639
+ if (!answer && current) answer = current;
3640
+ } else answer = unwrap(await p.text({
3641
+ message: field.message,
3642
+ placeholder: field.placeholder ?? field.defaultValue,
3643
+ initialValue: current,
3644
+ defaultValue: field.defaultValue,
3645
+ validate: check
3646
+ }));
3647
+ answers[field.name] = (answer ?? field.defaultValue ?? "").trim();
3648
+ }
3649
+ Object.assign(values, group.values(answers));
3650
+ }
3651
+ return values;
3652
+ }
3653
+ /** Writes values to a `.env` file, quoted where Compose needs it. Throws for a value it can't write. */
3654
+ function writeSettings(path, values) {
3655
+ const quoted = Object.fromEntries(Object.entries(values).map(([name, value]) => [name, quoteEnvValue(value)]));
3656
+ writeFileSync(path, setEnvValues(readFileSync(path, "utf8"), quoted), { mode: 384 });
3657
+ }
3658
+ /** The production `.env` of a project: `--file`, `.env.production`, or `.env` on a server. */
3659
+ function targetFile(directory, file) {
3660
+ if (file) {
3661
+ const path = join(directory, file);
3662
+ if (!existsSync(path)) fail(`${path} does not exist.`);
3663
+ return path;
3664
+ }
3665
+ const production = join(directory, ".env.production");
3666
+ if (existsSync(production)) return production;
3667
+ const env = join(directory, ".env");
3668
+ if (existsSync(env) && parseProfiles(parseEnv(readFileSync(env, "utf8")).COMPOSE_PROFILES).includes("prod")) return env;
3669
+ fail(`There is no .env.production yet. Write it with ${command("dstack production")}.`);
3670
+ }
3671
+ /** `dstack settings`: ask for the missing production settings and credentials, and save them. */
3672
+ async function settings(options) {
3673
+ const interactive = isInteractive(options.yes);
3674
+ intro("Production settings");
3675
+ const directory = findProjectDirectory(options.dir ?? ".");
3676
+ if (!directory) fail("No DjangoStack project here: run this where compose.yaml and .env are.");
3677
+ const target = targetFile(directory, options.file);
3678
+ const name = relative(directory, target);
3679
+ let assigned;
3680
+ try {
3681
+ assigned = parseAssignments(options.set ?? []);
3682
+ } catch (error) {
3683
+ fail(error.message);
3684
+ }
3685
+ const env = {
3686
+ ...parseEnv(readFileSync(target, "utf8")),
3687
+ ...assigned
3688
+ };
3689
+ const context = projectContext(directory);
3690
+ const missing = settingsToAsk(env, context);
3691
+ let values = assigned;
3692
+ if (interactive) {
3693
+ const groups = settingsToAsk(env, context, options.all);
3694
+ if (!groups.length && !Object.keys(assigned).length) {
3695
+ p.outro(pc.green(`Nothing is missing in ${name}`));
3696
+ return;
3697
+ }
3698
+ if (groups.length && !options.all) p.log.info(`${name} needs ${groups.map((group) => group.title.toLowerCase()).join(", ")}.`);
3699
+ values = {
3700
+ ...assigned,
3701
+ ...await askSettings(groups, env)
3702
+ };
3703
+ } else if (missing.length) fail(`${name} misses ${missing.flatMap((group) => group.missing(env)).join(", ")}. Pass them with --set NAME=value.`);
3704
+ else if (!Object.keys(assigned).length) {
3705
+ p.outro(pc.green(`Nothing is missing in ${name}`));
3706
+ return;
3707
+ }
3708
+ try {
3709
+ writeSettings(target, values);
3710
+ } catch (error) {
3711
+ fail(error.message);
3712
+ }
3713
+ p.log.success(`Saved ${Object.keys(values).join(", ")} in ${pc.bold(name)}`);
3714
+ if (existsSync(join(directory, ".dstack", "deploy.json")) && target.endsWith(".env.production")) p.outro(`Send them to the platform with ${command("dstack deploy env push")}`);
3715
+ else p.outro(pc.green("Done"));
3716
+ }
3652
3717
  // src/commands/production.ts
3653
3718
  async function production(directoryArgument, options) {
3654
3719
  const interactive = isInteractive(options.yes);
@@ -3667,18 +3732,36 @@ async function production(directoryArgument, options) {
3667
3732
  validate: (v) => validateDomain(v ?? "")
3668
3733
  }));
3669
3734
  }
3735
+ const context = projectContext(directory);
3670
3736
  let result;
3671
3737
  try {
3672
3738
  result = productionEnv$1(readFileSync(envPath, "utf8"), {
3673
3739
  domain,
3674
- emailUrl: options.emailUrl
3740
+ emailUrl: options.emailUrl,
3741
+ media: context.media
3675
3742
  });
3676
3743
  } catch (error) {
3677
3744
  fail(error.message);
3678
3745
  }
3679
3746
  writeFileSync(target, result.text, { mode: 384 });
3680
3747
  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");
3748
+ let todo = result.todo;
3749
+ const missing = settingsToAsk(parseEnv(result.text), context);
3750
+ if (interactive && missing.length) {
3751
+ if (unwrap(await p.confirm({
3752
+ message: `Set the ${missing.map((group) => group.title.toLowerCase()).join(", ")} now?`,
3753
+ initialValue: true
3754
+ }))) {
3755
+ try {
3756
+ writeSettings(target, await askSettings(missing, parseEnv(result.text)));
3757
+ } catch (error) {
3758
+ fail(error.message);
3759
+ }
3760
+ todo = remainingTodo(todo, parseEnv(readFileSync(target, "utf8")));
3761
+ }
3762
+ }
3763
+ if (todo.some((item) => SETTING_NAMES.some((name) => item.includes(name.startsWith("S3_") ? "S3_" : name)))) todo = [...todo, `Run ${command("dstack settings")} to set them.`];
3764
+ if (todo.length) p.note(todo.map((item) => `${pc.yellow("•")} ${item}`).join("\n"), "Before the first deploy");
3682
3765
  p.note([
3683
3766
  `Copy the project to the server, with ${pc.bold(".env.production")} as ${pc.bold(".env")}, then:`,
3684
3767
  command("docker compose up -d --build"),
@@ -3690,7 +3773,13 @@ async function production(directoryArgument, options) {
3690
3773
  }
3691
3774
  // src/config.ts
3692
3775
  /** The options `config` can switch in an existing project. The rest follow with code generation. */
3693
- const SWITCHABLE = ["storage", "analytics"];
3776
+ const SWITCHABLE = [
3777
+ "storage",
3778
+ "analytics",
3779
+ "media",
3780
+ "cache",
3781
+ "tasks"
3782
+ ];
3694
3783
  const EMPTY_S3 = Object.fromEntries(Object.keys(LOCAL_S3).map((key) => [key, key === "S3_CREATE_BUCKET" ? "0" : ""]));
3695
3784
  /**
3696
3785
  * Applies a new stack to a project's `.env`. Throws with a one-line reason
@@ -3713,11 +3802,30 @@ function applyStack(envText, env, from, to) {
3713
3802
  profiles = profiles.filter((profile) => profile !== "s3");
3714
3803
  if (to.storage === "s3") profiles.push("s3");
3715
3804
  text = setEnvValues(text, {
3716
- ...to.storage === "s3" ? LOCAL_S3 : EMPTY_S3,
3805
+ ...to.storage === "s3" ? localS3(envPorts(env).S3_PORT) : EMPTY_S3,
3717
3806
  COMPOSE_PROFILES: profiles.join(",")
3718
3807
  });
3719
3808
  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
3809
  }
3810
+ if (key === "cache") {
3811
+ text = setEnvValues(text, { CACHE_URL: cacheEnv(to).CACHE_URL ?? "" });
3812
+ changes.push({
3813
+ database: "The cache is a table in the database.",
3814
+ valkey: "The cache is in Valkey (the valkey profile).",
3815
+ off: "The cache is per process; nothing is shared."
3816
+ }[to.cache]);
3817
+ }
3818
+ if (key === "tasks" || key === "cache" && from.tasks === "huey" && to.tasks === "huey") {
3819
+ const { CACHE_URL: _, ...tasks } = cacheEnv(to);
3820
+ const before = parseEnv(text).TASKS_URL;
3821
+ text = setEnvValues(text, tasks);
3822
+ 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"}.`);
3823
+ }
3824
+ if (key === "cache" || key === "tasks") {
3825
+ profiles = profiles.filter((profile) => profile !== "valkey");
3826
+ if (usesValkey(parseEnv(text))) profiles.push("valkey");
3827
+ text = setEnvValues(text, { COMPOSE_PROFILES: profiles.join(",") });
3828
+ }
3721
3829
  if (key === "analytics") {
3722
3830
  profiles = profiles.filter((profile) => profile !== "analytics");
3723
3831
  if (to.analytics === "umami") {
@@ -3737,6 +3845,7 @@ function applyStack(envText, env, from, to) {
3737
3845
  changes.push("Page analytics are off; Umami's data stays in the database.");
3738
3846
  }
3739
3847
  }
3848
+ 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
3849
  }
3741
3850
  return {
3742
3851
  env: text,
@@ -3755,6 +3864,7 @@ function setManifestPreset(manifestText, preset) {
3755
3864
  // src/commands/project.ts
3756
3865
  function open(options) {
3757
3866
  if (!has("docker", ["compose", "version"])) fail("Docker Compose v2 was not found. Install Docker: https://docs.docker.com/get-docker/");
3867
+ if (!dockerRunning()) fail(dockerNotRunning());
3758
3868
  try {
3759
3869
  return readProject(options.dir ?? ".");
3760
3870
  } catch (error) {
@@ -3805,11 +3915,33 @@ async function run(options) {
3805
3915
  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
3916
  p.outro(`Stop it with ${command("dstack stop")}, follow the logs with ${command("dstack logs")}`);
3807
3917
  }
3918
+ /**
3919
+ * Moves the development services to free ports when theirs are in use, in
3920
+ * `.env`, unless the project already runs. compose.yaml from before the
3921
+ * *_PORT variables has fixed ports, so it can only say which one is in use.
3922
+ */
3923
+ async function freeDevPorts(project) {
3924
+ if (runningServices(project).length) return project;
3925
+ const { ports, moves, stuck } = await chooseFreePorts(project.env, project.profiles);
3926
+ const [blocked] = stuck;
3927
+ if (blocked) fail(`Port ${envPorts(project.env)[blocked.name]} of the ${blocked.label} is in use. Stop what uses it.`);
3928
+ if (!moves.length) return project;
3929
+ const composeText = readFileSync(join(project.directory, "compose.yaml"), "utf8");
3930
+ for (const move of moves) {
3931
+ const item = DEV_PORTS.find((candidate) => candidate.label === move.label);
3932
+ 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.`);
3933
+ }
3934
+ const envPath = join(project.directory, ".env");
3935
+ writeFileSync(envPath, setEnvValues(readFileSync(envPath, "utf8"), portValues(project.env, ports)), { mode: 384 });
3936
+ for (const move of moves) p.log.info(`${describeMove(move)} Saved in .env.`);
3937
+ return readProject(project.directory);
3938
+ }
3808
3939
  /** `dstack dev`: the dev profile in the foreground, with hot reload. */
3809
3940
  async function dev(options) {
3810
- const project = open(options);
3941
+ let project = open(options);
3811
3942
  if (project.prod) fail(`This .env runs the prod profile. Use ${command("dstack run")}.`);
3812
3943
  intro("Develop");
3944
+ project = await freeDevPorts(project);
3813
3945
  urlsNote(project, "Starting");
3814
3946
  p.log.message(pc.dim("Changes in backend/ and frontend/ reload. Press Ctrl+C to stop."));
3815
3947
  exitWith(compose(project, ["up", ...options.build ? ["--build"] : []]));
@@ -3857,23 +3989,34 @@ async function shell(options) {
3857
3989
  return manage(["shell"], options);
3858
3990
  }
3859
3991
  /**
3860
- * The project and the service that run the backups: the prod worker, on a
3992
+ * The project and how to run a backup command: in the prod worker, on a
3861
3993
  * server or the local run, or the `backup` service of projects from 0.1.
3994
+ * Without a running worker (tasks off, TASK_WORKERS=0) in a one-off worker
3995
+ * container, while the backend runs.
3862
3996
  */
3863
3997
  function backupProject(options) {
3864
3998
  const project = activeProject(open(options));
3865
3999
  if (!project.prod) fail(`Backups run in the prod profile: on the server, or locally after ${command("dstack run")}.`);
3866
4000
  const running = runningServices(project);
3867
4001
  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 {
4002
+ if (service) return {
3870
4003
  project,
3871
- service
4004
+ run: ["exec", service]
3872
4005
  };
4006
+ if (project.env.TASK_WORKERS === "0" && running.includes(project.services.backend)) return {
4007
+ project,
4008
+ run: [
4009
+ "run",
4010
+ "--rm",
4011
+ "--no-deps",
4012
+ project.services.worker
4013
+ ]
4014
+ };
4015
+ fail(`The ${project.services.worker} service is not running. Start the project with ${command("dstack run")}.`);
3873
4016
  }
3874
4017
  /** `dstack backup`: back up the database and the media now, or list the backups. */
3875
4018
  async function backup(options) {
3876
- const { project, service } = backupProject(options);
4019
+ const { project, run } = backupProject(options);
3877
4020
  const args = [
3878
4021
  "python",
3879
4022
  "manage.py",
@@ -3882,9 +4025,9 @@ async function backup(options) {
3882
4025
  if (options.list) args.push("--list");
3883
4026
  else if (!options.media) args.push("--no-media");
3884
4027
  exitWith(compose(project, [
3885
- "exec",
4028
+ run[0],
3886
4029
  "-T",
3887
- service,
4030
+ ...run.slice(1),
3888
4031
  ...args
3889
4032
  ]));
3890
4033
  }
@@ -3909,13 +4052,13 @@ function restoreArgs(name, options) {
3909
4052
  }
3910
4053
  /** `dstack restore <backup>`: restore a database dump, with --media its media too, or a media archive. */
3911
4054
  async function restore(name, options) {
3912
- const { project, service } = backupProject(options);
4055
+ const { project, run } = backupProject(options);
3913
4056
  const args = restoreArgs(name, options);
3914
4057
  if (!options.yes && !tty()) fail("Pass --yes to restore without a terminal.");
3915
4058
  exitWith(compose(project, [
3916
- "exec",
4059
+ run[0],
3917
4060
  ...options.yes ? ["-T"] : [],
3918
- service,
4061
+ ...run.slice(1),
3919
4062
  ...args
3920
4063
  ]));
3921
4064
  }
@@ -4026,7 +4169,8 @@ function checkSteps(project, tests) {
4026
4169
  }
4027
4170
  /** `dstack check`: the doctor's checks, Django's checks, the linters and, with --tests, the tests. */
4028
4171
  async function check(options) {
4029
- const project = activeProject(open(options));
4172
+ const base = open(options);
4173
+ const project = activeProject(base);
4030
4174
  intro("Check");
4031
4175
  let failed = 0;
4032
4176
  const spinner = p.spinner();
@@ -4107,11 +4251,12 @@ async function update(options) {
4107
4251
  } else if (existsSync(lock)) p.log.warn("uv is not installed, so backend/uv.lock stays as it is: https://docs.astral.sh/uv/");
4108
4252
  for (const target of targets) {
4109
4253
  if (compose(target, ["pull", "--ignore-buildable"]) !== 0) fail("Pulling the images failed.");
4110
- if (compose(target, [
4254
+ const build = [
4111
4255
  "build",
4112
4256
  "--pull",
4113
4257
  ...options.cache ? [] : ["--no-cache"]
4114
- ]) !== 0) fail("Building the images failed.");
4258
+ ];
4259
+ if (compose(target, build) !== 0) fail("Building the images failed.");
4115
4260
  }
4116
4261
  const volume = project.prod ? void 0 : nodeModulesVolume(project);
4117
4262
  if (volume) {
@@ -4153,20 +4298,12 @@ async function config(options) {
4153
4298
  intro("Configure");
4154
4299
  const current = projectStack(project);
4155
4300
  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
- }
4301
+ for (const key of PROMPT_ORDER) if (options.fromCli(key)) next[key] = options[key];
4302
+ else if (interactive && SWITCHABLE.includes(key)) next[key] = unwrap(await p.select({
4303
+ message: stackOption(key).message,
4304
+ initialValue: current[key],
4305
+ options: choices(key, next)
4306
+ }));
4170
4307
  if (next.analytics === "umami" && current.analytics !== "umami") {
4171
4308
  const compose = join(project.directory, "compose.yaml");
4172
4309
  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,18 +4319,192 @@ async function config(options) {
4182
4319
  p.outro("Nothing changed");
4183
4320
  return;
4184
4321
  }
4185
- writeFileSync(envPath, result.env, { mode: 384 });
4322
+ const envText = readFileSync(envPath, "utf8");
4323
+ if (result.env !== envText) writeFileSync(envPath, result.env, { mode: 384 });
4186
4324
  const manifestPath = join(project.directory, "package.json");
4187
4325
  if (existsSync(manifestPath)) writeFileSync(manifestPath, setManifestPreset(readFileSync(manifestPath, "utf8"), encodePreset(next)));
4188
4326
  for (const change of result.changes) p.log.success(change);
4189
- p.outro(`Restart to apply it: ${command("dstack stop")}, then ${command("dstack dev")}`);
4327
+ if (result.env === envText) p.outro(pc.green("Done"));
4328
+ else p.outro(`Restart to apply it: ${command("dstack stop")}, then ${command("dstack dev")}`);
4329
+ }
4330
+ // src/update-check.ts
4331
+ const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
4332
+ const DAY = 864e5;
4333
+ /** `$XDG_CACHE_HOME/django-stack/update-check.json`, or under `~/.cache`. */
4334
+ function defaultCachePath(env = process.env) {
4335
+ const base = env.XDG_CACHE_HOME || process.platform === "win32" && env.LOCALAPPDATA || join(homedir(), ".cache");
4336
+ return join(base, "django-stack", "update-check.json");
4337
+ }
4338
+ /** Whether to check at all: never in CI, without a terminal, or when turned off. */
4339
+ function updateCheckEnabled(env = process.env, isTTY = Boolean(process.stdout.isTTY)) {
4340
+ if (!isTTY) return false;
4341
+ if (env.CI && env.CI !== "0" && env.CI !== "false") return false;
4342
+ for (const key of ["DJANGO_STACK_NO_UPDATE_CHECK", "NO_UPDATE_NOTIFIER"]) if (env[key] && env[key] !== "0") return false;
4343
+ return true;
4344
+ }
4345
+ function parse(version) {
4346
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(version.trim());
4347
+ if (!match) return void 0;
4348
+ return [
4349
+ Number(match[1]),
4350
+ Number(match[2]),
4351
+ Number(match[3]),
4352
+ match[4] ?? ""
4353
+ ];
4354
+ }
4355
+ /** Whether `latest` is a newer version than `current`. Prereleases of `latest` never count. */
4356
+ function isNewer(latest, current) {
4357
+ const a = parse(latest);
4358
+ const b = parse(current);
4359
+ if (!a || !b || a[3]) return false;
4360
+ for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
4361
+ return b[3] !== "";
4362
+ }
4363
+ function readCache(path) {
4364
+ try {
4365
+ const data = JSON.parse(readFileSync(path, "utf8"));
4366
+ if (typeof data.checkedAt === "number" && typeof data.latest === "string") return data;
4367
+ } catch {}
4368
+ }
4369
+ function writeCache(path, cache) {
4370
+ try {
4371
+ mkdirSync(dirname(path), { recursive: true });
4372
+ writeFileSync(path, JSON.stringify(cache));
4373
+ } catch {}
4374
+ }
4375
+ /** The latest version on npm. Throws when the registry can't be reached. */
4376
+ async function fetchLatestVersion(options = {}) {
4377
+ const response = await (options.fetch ?? fetch)(REGISTRY_URL, {
4378
+ headers: { accept: "application/json" },
4379
+ signal: AbortSignal.timeout(options.timeout ?? 1500)
4380
+ });
4381
+ if (!response.ok) throw new Error(`The npm registry answered ${response.status}.`);
4382
+ const { version } = await response.json();
4383
+ if (typeof version !== "string") throw new Error("The npm registry sent no version.");
4384
+ return version;
4385
+ }
4386
+ /**
4387
+ * The latest version on npm when it is newer than `current`, otherwise
4388
+ * undefined. Uses a cached result for a day; never throws.
4389
+ */
4390
+ async function checkForUpdate(options) {
4391
+ const cachePath = options.cachePath ?? defaultCachePath();
4392
+ const now = options.now ?? Date.now;
4393
+ const maxAge = options.maxAge ?? DAY;
4394
+ let latest = readCache(cachePath);
4395
+ if (!latest || now() - latest.checkedAt > maxAge || latest.checkedAt > now()) try {
4396
+ latest = {
4397
+ checkedAt: now(),
4398
+ latest: await fetchLatestVersion(options)
4399
+ };
4400
+ writeCache(cachePath, latest);
4401
+ } catch {
4402
+ return;
4403
+ }
4404
+ return isNewer(latest.latest, options.current) ? latest.latest : void 0;
4405
+ }
4406
+ /**
4407
+ * The command that gets the latest version: through the runner for npx,
4408
+ * pnpm dlx and bunx, otherwise `dstack self-update`.
4409
+ */
4410
+ function updateCommand(location = import.meta.url, env = process.env) {
4411
+ const install = installation(location, env);
4412
+ if (install.kind === "temporary") {
4413
+ if (install.runner === "pnpm") return `pnpm dlx ${PACKAGE_NAME}@latest`;
4414
+ if (install.runner === "bun") return `bunx ${PACKAGE_NAME}@latest`;
4415
+ return `npx ${PACKAGE_NAME}@latest`;
4416
+ }
4417
+ if (install.kind === "checkout") return "git pull";
4418
+ return "dstack self-update";
4419
+ }
4420
+ // src/commands/self-update.ts
4421
+ /** The command each package manager runs a package with, without installing it. */
4422
+ const RUN_WITHOUT_INSTALL = {
4423
+ npm: "npx",
4424
+ pnpm: "pnpm dlx",
4425
+ bun: "bunx",
4426
+ yarn: "yarn dlx"
4427
+ };
4428
+ /**
4429
+ * The npm prefix this copy is installed in, such as `/usr/local` for
4430
+ * `/usr/local/lib/node_modules/@django-stack/cli`, so the update replaces this
4431
+ * copy even when another Node.js version's npm comes first on the `PATH`.
4432
+ */
4433
+ function npmPrefix(location = import.meta.url) {
4434
+ if (!location.startsWith("file:")) return void 0;
4435
+ const path = fileURLToPath(location);
4436
+ return /^(.+)\/lib\/node_modules\/@django-stack\/cli\//.exec(path)?.[1];
4437
+ }
4438
+ /** The version of the `dstack` on the `PATH`, or undefined when there is none. */
4439
+ function versionOnPath() {
4440
+ try {
4441
+ return execFileSync(BIN, ["--version"], {
4442
+ encoding: "utf8",
4443
+ shell: process.platform === "win32",
4444
+ stdio: [
4445
+ "ignore",
4446
+ "pipe",
4447
+ "ignore"
4448
+ ],
4449
+ env: {
4450
+ ...process.env,
4451
+ DJANGO_STACK_NO_UPDATE_CHECK: "1"
4452
+ }
4453
+ }).trim();
4454
+ } catch {
4455
+ return;
4456
+ }
4457
+ }
4458
+ /** Update the installed CLI to the latest version on npm, with the package manager that installed it. */
4459
+ async function selfUpdate() {
4460
+ intro("Self-update");
4461
+ const install = installation();
4462
+ if (install.kind === "checkout") fail(`This ${BIN} runs from a DjangoStack checkout. Update it with git pull and pnpm build.`);
4463
+ if (install.kind === "temporary") {
4464
+ const runWith = RUN_WITHOUT_INSTALL[install.runner];
4465
+ fail(`This ${BIN} runs through ${runWith} and is not installed. Run ${runWith} ${PACKAGE_NAME}@latest for the latest version, or install it with ${installCommand(install.runner).join(" ")}.`);
4466
+ }
4467
+ const { runner } = install;
4468
+ const spinner = p.spinner();
4469
+ spinner.start("Checking npm for the latest version");
4470
+ let latest;
4471
+ try {
4472
+ latest = await fetchLatestVersion({ timeout: 1e4 });
4473
+ } catch (error) {
4474
+ spinner.error("Couldn't check npm");
4475
+ fail(`Couldn't reach the npm registry: ${error.message}`);
4476
+ }
4477
+ if (!isNewer(latest, CLI_VERSION)) {
4478
+ spinner.stop(`${BIN} ${CLI_VERSION} is the latest version`);
4479
+ p.outro(pc.green("Nothing to update"));
4480
+ return;
4481
+ }
4482
+ spinner.stop(`Version ${pc.green(pc.bold(latest))} is available`);
4483
+ const prefix = runner === "npm" ? npmPrefix() : void 0;
4484
+ const args = prefix ? [
4485
+ ...installCommand(runner),
4486
+ "--prefix",
4487
+ prefix
4488
+ ] : installCommand(runner);
4489
+ if (!has(runner)) fail(`${runner} installed ${BIN} but is not on your PATH. Run ${args.join(" ")} yourself.`);
4490
+ spinner.start(`Updating with ${command(args.join(" "))}`);
4491
+ const result = await runInstall(args);
4492
+ if (!result.ok) {
4493
+ spinner.error(`Couldn't update ${BIN}`);
4494
+ fail(explainFailure(runner, result.output));
4495
+ }
4496
+ spinner.stop(`Updated ${BIN} ${pc.dim(CLI_VERSION)} ${pc.dim("→")} ${pc.green(pc.bold(latest))}`);
4497
+ const onPath = versionOnPath();
4498
+ if (onPath && onPath !== latest) p.log.warn(`The ${command(BIN)} on your PATH is still ${onPath}: another install comes first. Remove it, or update it with its own package manager.`);
4499
+ p.outro(pc.green("Done"));
4190
4500
  }
4191
4501
  // src/program.ts
4192
- /** Commands that print machine-readable output, so no update notice. */
4502
+ /** Commands that print machine-readable output or update the CLI, so no update notice. */
4193
4503
  const QUIET_COMMANDS = /* @__PURE__ */ new Set([
4194
4504
  "secret",
4195
4505
  "complete",
4196
- "completion"
4506
+ "completion",
4507
+ "self-update"
4197
4508
  ]);
4198
4509
  /** A flag for each stack option, such as `--cache <cache>`. */
4199
4510
  function stackFlags() {
@@ -4263,7 +4574,9 @@ function createProgram() {
4263
4574
  }));
4264
4575
  for (const flag of stackFlags()) presetCommand.addOption(flag);
4265
4576
  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);
4577
+ 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
4578
  program.command("doctor").description("Check a project and its services").argument("[directory]", "The project directory", ".").action(doctor);
4579
+ program.command("self-update").description(`Update ${BIN} to the latest version on npm`).action(selfUpdate);
4267
4580
  program.command("secret").description("Print a random secret").argument("[bytes]", "The number of random bytes", positiveInteger, 32).action((bytes) => console.log(generateSecret(bytes)));
4268
4581
  tab(program, { completionCommandName: "completion" });
4269
4582
  program.commands.find((cmd) => cmd.name() === "completion")?.description("Print a shell completion script (zsh, bash, fish or powershell)").addHelpText("after", `\nAdd it to your shell, for example in ~/.zshrc:\n ${pc.cyan("source <(dstack completion zsh)")}`);