@django-stack/cli 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/bin.js +660 -347
- package/dist/index.d.ts +51 -6
- package/dist/index.js +1 -1
- package/dist/{production-DIj9yRcg.js → production-CGkI7oNe.js} +641 -57
- package/package.json +1 -1
- package/templates/backend/Dockerfile +4 -2
- package/templates/backend/pyproject.toml +1 -1
- package/templates/base/README.md +9 -4
- package/templates/base/compose.yaml +19 -9
- package/templates/base/env.example +23 -4
- package/templates/frontend-astro/package.json +1 -1
- package/templates/frontend-next/package.json +3 -3
- package/templates/frontend-nuxt/package.json +2 -2
- package/templates/frontend-sveltekit/package.json +2 -2
package/dist/bin.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { C as
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
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
|
|
3449
|
-
const
|
|
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
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
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(
|
|
3383
|
+
command(startCommand),
|
|
3609
3384
|
"",
|
|
3610
|
-
`${pc.bold("Site")} ${url(
|
|
3611
|
-
`${pc.bold("Admin")} ${url(
|
|
3612
|
-
`${pc.bold("Mail")} ${url(
|
|
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(
|
|
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 =
|
|
3641
|
-
const
|
|
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
|
-
|
|
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 = [
|
|
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" ?
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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,
|
|
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
|
-
|
|
4028
|
+
run[0],
|
|
3886
4029
|
"-T",
|
|
3887
|
-
|
|
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,
|
|
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
|
-
|
|
4059
|
+
run[0],
|
|
3917
4060
|
...options.yes ? ["-T"] : [],
|
|
3918
|
-
|
|
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
|
|
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
|
-
|
|
4254
|
+
const build = [
|
|
4111
4255
|
"build",
|
|
4112
4256
|
"--pull",
|
|
4113
4257
|
...options.cache ? [] : ["--no-cache"]
|
|
4114
|
-
]
|
|
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
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
options: option.values.map((value) => ({
|
|
4163
|
-
value: value.value,
|
|
4164
|
-
label: value.label,
|
|
4165
|
-
hint: value.generated ? value.hint : "coming soon",
|
|
4166
|
-
disabled: !value.generated && value.value !== current[key]
|
|
4167
|
-
}))
|
|
4168
|
-
}));
|
|
4169
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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)")}`);
|