@django-stack/cli 0.7.0 → 0.8.1
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 +221 -314
- package/dist/index.js +1 -1
- package/dist/{production-DBpWWx6i.js → production-CGkI7oNe.js} +266 -23
- package/package.json +1 -1
- package/templates/backend/Dockerfile +4 -2
- package/templates/backend/pyproject.toml +1 -1
- package/templates/frontend-astro/Dockerfile +4 -1
- 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/README.md
CHANGED
|
@@ -14,6 +14,7 @@ docker compose up
|
|
|
14
14
|
| `production [directory]` | Write `.env.production` for a server. Flags: `--domain`, `--email-url`, `--force`, `--yes` |
|
|
15
15
|
| `preset [preset]` | Print the preset code of a stack, or the stack of a code |
|
|
16
16
|
| `doctor [directory]` | Check a project and its running services |
|
|
17
|
+
| `self-update` | Update `dstack` to the latest version, with the package manager that installed it |
|
|
17
18
|
| `secret [bytes]` | Print a random secret |
|
|
18
19
|
| `completion <shell>` | Print a Tab completion script for zsh, bash, fish or PowerShell |
|
|
19
20
|
|
package/dist/bin.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { A 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
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";
|
|
@@ -2495,228 +2496,6 @@ function getProvider(id) {
|
|
|
2495
2496
|
if (!provider) throw new Error(`Unknown deploy provider ${id}. Choose one of: ${PROVIDERS.map((p) => p.id).join(", ")}.`);
|
|
2496
2497
|
return provider;
|
|
2497
2498
|
}
|
|
2498
|
-
// src/project.ts
|
|
2499
|
-
/** The `.env` of `dstack run` on a development machine: the prod profile at https://site.localhost. */
|
|
2500
|
-
const RUN_ENV = ".env.run";
|
|
2501
|
-
const RUN_DOMAIN = "site.localhost";
|
|
2502
|
-
/** The project directory: `start` or the nearest parent with `compose.yaml` and `.env`. */
|
|
2503
|
-
function findProjectDirectory(start) {
|
|
2504
|
-
let directory = resolve(start);
|
|
2505
|
-
for (;;) {
|
|
2506
|
-
if (existsSync(join(directory, "compose.yaml")) && existsSync(join(directory, ".env"))) return directory;
|
|
2507
|
-
const parent = dirname(directory);
|
|
2508
|
-
if (parent === directory) return void 0;
|
|
2509
|
-
directory = parent;
|
|
2510
|
-
}
|
|
2511
|
-
}
|
|
2512
|
-
function parseProfiles(value) {
|
|
2513
|
-
return (value ?? "").split(",").map((profile) => profile.trim()).filter(Boolean);
|
|
2514
|
-
}
|
|
2515
|
-
function projectName(directory) {
|
|
2516
|
-
try {
|
|
2517
|
-
const match = readFileSync(join(directory, "compose.yaml"), "utf8").match(/^name:\s*["']?([\w.-]+)/m);
|
|
2518
|
-
if (match?.[1]) return match[1];
|
|
2519
|
-
} catch {}
|
|
2520
|
-
return basename(directory).toLowerCase();
|
|
2521
|
-
}
|
|
2522
|
-
function fromEnv(directory, name, envFile, localRun) {
|
|
2523
|
-
const env = parseEnv(readFileSync(join(directory, envFile), "utf8"));
|
|
2524
|
-
const profiles = parseProfiles(env.COMPOSE_PROFILES);
|
|
2525
|
-
const prod = profiles.includes("prod");
|
|
2526
|
-
const suffix = prod ? "-prod" : "";
|
|
2527
|
-
return {
|
|
2528
|
-
directory,
|
|
2529
|
-
name: localRun ? `${name}-run` : name,
|
|
2530
|
-
env,
|
|
2531
|
-
profiles,
|
|
2532
|
-
prod,
|
|
2533
|
-
localRun,
|
|
2534
|
-
services: {
|
|
2535
|
-
backend: `backend${suffix}`,
|
|
2536
|
-
frontend: `frontend${suffix}`,
|
|
2537
|
-
worker: `worker${suffix}`
|
|
2538
|
-
},
|
|
2539
|
-
composeArgs: localRun ? [
|
|
2540
|
-
"--env-file",
|
|
2541
|
-
envFile,
|
|
2542
|
-
"--project-name",
|
|
2543
|
-
`${name}-run`
|
|
2544
|
-
] : [],
|
|
2545
|
-
composeEnv: localRun ? {
|
|
2546
|
-
ENV_FILE: envFile,
|
|
2547
|
-
COMPOSE_PROFILES: env.COMPOSE_PROFILES ?? ""
|
|
2548
|
-
} : {}
|
|
2549
|
-
};
|
|
2550
|
-
}
|
|
2551
|
-
/** Reads the project in or above `start`. Throws with a one-line reason when there is none. */
|
|
2552
|
-
function readProject(start = ".") {
|
|
2553
|
-
const directory = findProjectDirectory(start);
|
|
2554
|
-
if (!directory) throw new Error(`No DjangoStack project in ${resolve(start)}: run this where compose.yaml and .env are.`);
|
|
2555
|
-
return fromEnv(directory, projectName(directory), ".env", false);
|
|
2556
|
-
}
|
|
2557
|
-
/** The local production run of a development project, when `.env.run` exists. */
|
|
2558
|
-
function readLocalRun(project) {
|
|
2559
|
-
if (project.prod || project.localRun || !existsSync(join(project.directory, ".env.run"))) return void 0;
|
|
2560
|
-
return fromEnv(project.directory, projectName(project.directory), RUN_ENV, true);
|
|
2561
|
-
}
|
|
2562
|
-
/** The `django-stack` section of the project's `package.json`. */
|
|
2563
|
-
function projectManifest(project) {
|
|
2564
|
-
try {
|
|
2565
|
-
return JSON.parse(readFileSync(join(project.directory, "package.json"), "utf8"))["django-stack"] ?? {};
|
|
2566
|
-
} catch {
|
|
2567
|
-
return {};
|
|
2568
|
-
}
|
|
2569
|
-
}
|
|
2570
|
-
/**
|
|
2571
|
-
* The project's stack: from the preset `init` saved in `package.json`, or,
|
|
2572
|
-
* for projects from before that, the defaults with the storage from `.env`.
|
|
2573
|
-
* Analytics follow the `analytics` profile: projects from before Umami was
|
|
2574
|
-
* generated have the default in their preset without running it.
|
|
2575
|
-
*/
|
|
2576
|
-
function projectStack(project) {
|
|
2577
|
-
const fromEnv = {
|
|
2578
|
-
analytics: project.profiles.includes("analytics") ? "umami" : "off",
|
|
2579
|
-
...project.env.CACHE_URL === void 0 ? {} : cacheStack(project.env)
|
|
2580
|
-
};
|
|
2581
|
-
const { preset } = projectManifest(project);
|
|
2582
|
-
if (preset) try {
|
|
2583
|
-
return {
|
|
2584
|
-
...decodePreset(preset),
|
|
2585
|
-
...fromEnv
|
|
2586
|
-
};
|
|
2587
|
-
} catch {}
|
|
2588
|
-
return {
|
|
2589
|
-
...DEFAULT_STACK,
|
|
2590
|
-
...fromEnv,
|
|
2591
|
-
storage: project.profiles.includes("s3") ? "s3" : "local"
|
|
2592
|
-
};
|
|
2593
|
-
}
|
|
2594
|
-
const REDIS = /^(redis|rediss|valkey|valkeys):/;
|
|
2595
|
-
/**
|
|
2596
|
-
* The cache and the tasks a project's `.env` runs. Projects from before 0.7
|
|
2597
|
-
* have no TASKS_URL: their tasks follow a Redis cache, like the backend does.
|
|
2598
|
-
*/
|
|
2599
|
-
function cacheStack(env) {
|
|
2600
|
-
const cacheUrl = env.CACHE_URL ?? "";
|
|
2601
|
-
const cache = cacheUrl.startsWith("db:") ? "database" : REDIS.test(cacheUrl) ? "valkey" : "off";
|
|
2602
|
-
const tasksUrl = env.TASKS_URL || (cache === "valkey" ? cacheUrl : "immediate://");
|
|
2603
|
-
if (tasksUrl.startsWith("immediate:")) return {
|
|
2604
|
-
cache,
|
|
2605
|
-
tasks: "off"
|
|
2606
|
-
};
|
|
2607
|
-
return {
|
|
2608
|
-
cache,
|
|
2609
|
-
tasks: REDIS.test(tasksUrl) && cache !== "valkey" ? "huey-valkey" : "huey"
|
|
2610
|
-
};
|
|
2611
|
-
}
|
|
2612
|
-
/** The URLs of a running project. */
|
|
2613
|
-
function projectUrls(project) {
|
|
2614
|
-
if (project.prod) {
|
|
2615
|
-
const site = (project.env.SITE_URL ?? "").replace(/\/$/, "");
|
|
2616
|
-
return [{
|
|
2617
|
-
label: "Site",
|
|
2618
|
-
url: `${site}/`
|
|
2619
|
-
}, {
|
|
2620
|
-
label: "Admin",
|
|
2621
|
-
url: `${site}/admin/`
|
|
2622
|
-
}];
|
|
2623
|
-
}
|
|
2624
|
-
const ports = envPorts(project.env);
|
|
2625
|
-
const site = (project.env.SITE_URL || `http://localhost:${ports.SITE_PORT}`).replace(/\/$/, "");
|
|
2626
|
-
const urls = [
|
|
2627
|
-
{
|
|
2628
|
-
label: "Site",
|
|
2629
|
-
url: site
|
|
2630
|
-
},
|
|
2631
|
-
{
|
|
2632
|
-
label: "Admin",
|
|
2633
|
-
url: `${site}/admin/`
|
|
2634
|
-
},
|
|
2635
|
-
{
|
|
2636
|
-
label: "Mail",
|
|
2637
|
-
url: `http://localhost:${ports.MAIL_PORT}`
|
|
2638
|
-
}
|
|
2639
|
-
];
|
|
2640
|
-
if (project.profiles.includes("s3")) urls.push({
|
|
2641
|
-
label: "S3",
|
|
2642
|
-
url: `http://localhost:${ports.S3_PORT}`
|
|
2643
|
-
});
|
|
2644
|
-
return urls;
|
|
2645
|
-
}
|
|
2646
|
-
/** Runs a command with the terminal attached and returns its exit status. */
|
|
2647
|
-
const runInherit = (cmd, args, options) => {
|
|
2648
|
-
const result = spawnSync(cmd, args, {
|
|
2649
|
-
stdio: "inherit",
|
|
2650
|
-
...options
|
|
2651
|
-
});
|
|
2652
|
-
if (result.error) return 127;
|
|
2653
|
-
return result.status ?? 1;
|
|
2654
|
-
};
|
|
2655
|
-
/** The full `docker compose` argument list for a project. */
|
|
2656
|
-
function composeCommand(project, args) {
|
|
2657
|
-
return [
|
|
2658
|
-
"compose",
|
|
2659
|
-
...project.composeArgs,
|
|
2660
|
-
...args
|
|
2661
|
-
];
|
|
2662
|
-
}
|
|
2663
|
-
function composeOptions(project) {
|
|
2664
|
-
return {
|
|
2665
|
-
cwd: project.directory,
|
|
2666
|
-
env: {
|
|
2667
|
-
...process.env,
|
|
2668
|
-
...project.composeEnv
|
|
2669
|
-
}
|
|
2670
|
-
};
|
|
2671
|
-
}
|
|
2672
|
-
/** Runs `docker compose <args>` for the project with the terminal attached. */
|
|
2673
|
-
function compose(project, args, run = runInherit) {
|
|
2674
|
-
return run("docker", composeCommand(project, args), composeOptions(project));
|
|
2675
|
-
}
|
|
2676
|
-
/** Runs `docker compose <args>` and captures the output. */
|
|
2677
|
-
function composeOutput(project, args) {
|
|
2678
|
-
const result = spawnSync("docker", composeCommand(project, args), {
|
|
2679
|
-
...composeOptions(project),
|
|
2680
|
-
encoding: "utf8",
|
|
2681
|
-
stdio: [
|
|
2682
|
-
"ignore",
|
|
2683
|
-
"pipe",
|
|
2684
|
-
"pipe"
|
|
2685
|
-
]
|
|
2686
|
-
});
|
|
2687
|
-
return {
|
|
2688
|
-
status: result.status ?? 1,
|
|
2689
|
-
output: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim()
|
|
2690
|
-
};
|
|
2691
|
-
}
|
|
2692
|
-
/** The running services of the project. */
|
|
2693
|
-
function runningServices(project) {
|
|
2694
|
-
const result = spawnSync("docker", composeCommand(project, [
|
|
2695
|
-
"ps",
|
|
2696
|
-
"--services",
|
|
2697
|
-
"--filter",
|
|
2698
|
-
"status=running"
|
|
2699
|
-
]), {
|
|
2700
|
-
...composeOptions(project),
|
|
2701
|
-
encoding: "utf8",
|
|
2702
|
-
stdio: [
|
|
2703
|
-
"ignore",
|
|
2704
|
-
"pipe",
|
|
2705
|
-
"ignore"
|
|
2706
|
-
]
|
|
2707
|
-
});
|
|
2708
|
-
return result.status === 0 ? result.stdout.split("\n").filter(Boolean) : [];
|
|
2709
|
-
}
|
|
2710
|
-
/**
|
|
2711
|
-
* The project a command acts on: the local production run when it is the
|
|
2712
|
-
* one running, otherwise the project `.env` describes.
|
|
2713
|
-
*/
|
|
2714
|
-
function activeProject(project) {
|
|
2715
|
-
const localRun = readLocalRun(project);
|
|
2716
|
-
if (!localRun) return project;
|
|
2717
|
-
if (runningServices(project).length) return project;
|
|
2718
|
-
return runningServices(localRun).length ? localRun : project;
|
|
2719
|
-
}
|
|
2720
2499
|
// src/ui.ts
|
|
2721
2500
|
/** The DjangoStack badge with the version and an optional title. */
|
|
2722
2501
|
function intro(title) {
|
|
@@ -3195,90 +2974,6 @@ async function doctor(directoryArgument) {
|
|
|
3195
2974
|
}
|
|
3196
2975
|
p.outro(pc.green("All checks passed"));
|
|
3197
2976
|
}
|
|
3198
|
-
// src/update-check.ts
|
|
3199
|
-
const PACKAGE_NAME = "@django-stack/cli";
|
|
3200
|
-
const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
3201
|
-
const DAY = 864e5;
|
|
3202
|
-
/** `$XDG_CACHE_HOME/django-stack/update-check.json`, or under `~/.cache`. */
|
|
3203
|
-
function defaultCachePath(env = process.env) {
|
|
3204
|
-
const base = env.XDG_CACHE_HOME || process.platform === "win32" && env.LOCALAPPDATA || join(homedir(), ".cache");
|
|
3205
|
-
return join(base, "django-stack", "update-check.json");
|
|
3206
|
-
}
|
|
3207
|
-
/** Whether to check at all: never in CI, without a terminal, or when turned off. */
|
|
3208
|
-
function updateCheckEnabled(env = process.env, isTTY = Boolean(process.stdout.isTTY)) {
|
|
3209
|
-
if (!isTTY) return false;
|
|
3210
|
-
if (env.CI && env.CI !== "0" && env.CI !== "false") return false;
|
|
3211
|
-
for (const key of ["DJANGO_STACK_NO_UPDATE_CHECK", "NO_UPDATE_NOTIFIER"]) if (env[key] && env[key] !== "0") return false;
|
|
3212
|
-
return true;
|
|
3213
|
-
}
|
|
3214
|
-
function parse(version) {
|
|
3215
|
-
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(version.trim());
|
|
3216
|
-
if (!match) return void 0;
|
|
3217
|
-
return [
|
|
3218
|
-
Number(match[1]),
|
|
3219
|
-
Number(match[2]),
|
|
3220
|
-
Number(match[3]),
|
|
3221
|
-
match[4] ?? ""
|
|
3222
|
-
];
|
|
3223
|
-
}
|
|
3224
|
-
/** Whether `latest` is a newer version than `current`. Prereleases of `latest` never count. */
|
|
3225
|
-
function isNewer(latest, current) {
|
|
3226
|
-
const a = parse(latest);
|
|
3227
|
-
const b = parse(current);
|
|
3228
|
-
if (!a || !b || a[3]) return false;
|
|
3229
|
-
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
|
|
3230
|
-
return b[3] !== "";
|
|
3231
|
-
}
|
|
3232
|
-
function readCache(path) {
|
|
3233
|
-
try {
|
|
3234
|
-
const data = JSON.parse(readFileSync(path, "utf8"));
|
|
3235
|
-
if (typeof data.checkedAt === "number" && typeof data.latest === "string") return data;
|
|
3236
|
-
} catch {}
|
|
3237
|
-
}
|
|
3238
|
-
function writeCache(path, cache) {
|
|
3239
|
-
try {
|
|
3240
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
3241
|
-
writeFileSync(path, JSON.stringify(cache));
|
|
3242
|
-
} catch {}
|
|
3243
|
-
}
|
|
3244
|
-
/**
|
|
3245
|
-
* The latest version on npm when it is newer than `current`, otherwise
|
|
3246
|
-
* undefined. Uses a cached result for a day; never throws.
|
|
3247
|
-
*/
|
|
3248
|
-
async function checkForUpdate(options) {
|
|
3249
|
-
const cachePath = options.cachePath ?? defaultCachePath();
|
|
3250
|
-
const now = options.now ?? Date.now;
|
|
3251
|
-
const maxAge = options.maxAge ?? DAY;
|
|
3252
|
-
let latest = readCache(cachePath);
|
|
3253
|
-
if (!latest || now() - latest.checkedAt > maxAge || latest.checkedAt > now()) try {
|
|
3254
|
-
const response = await (options.fetch ?? fetch)(REGISTRY_URL, {
|
|
3255
|
-
headers: { accept: "application/json" },
|
|
3256
|
-
signal: AbortSignal.timeout(options.timeout ?? 1500)
|
|
3257
|
-
});
|
|
3258
|
-
if (!response.ok) return void 0;
|
|
3259
|
-
const { version } = await response.json();
|
|
3260
|
-
if (typeof version !== "string") return void 0;
|
|
3261
|
-
latest = {
|
|
3262
|
-
checkedAt: now(),
|
|
3263
|
-
latest: version
|
|
3264
|
-
};
|
|
3265
|
-
writeCache(cachePath, latest);
|
|
3266
|
-
} catch {
|
|
3267
|
-
return;
|
|
3268
|
-
}
|
|
3269
|
-
return isNewer(latest.latest, options.current) ? latest.latest : void 0;
|
|
3270
|
-
}
|
|
3271
|
-
/** The command that runs or installs the latest version, depending on how this one was started. */
|
|
3272
|
-
function updateCommand(location = import.meta.url, env = process.env) {
|
|
3273
|
-
const path = decodeURIComponent(location).replaceAll("\\", "/");
|
|
3274
|
-
if (path.includes("/_npx/")) return `npx ${PACKAGE_NAME}@latest`;
|
|
3275
|
-
if (path.includes("/dlx/") || path.includes("/dlx-")) return `pnpm dlx ${PACKAGE_NAME}@latest`;
|
|
3276
|
-
if (path.includes("/bunx-")) return `bunx ${PACKAGE_NAME}@latest`;
|
|
3277
|
-
if (path.includes("/pnpm/global/")) return `pnpm add -g ${PACKAGE_NAME}@latest`;
|
|
3278
|
-
if (path.includes("/.bun/install/global/")) return `bun add -g ${PACKAGE_NAME}@latest`;
|
|
3279
|
-
if (env.npm_config_user_agent?.startsWith("pnpm")) return `pnpm add -g ${PACKAGE_NAME}@latest`;
|
|
3280
|
-
return `npm i -g ${PACKAGE_NAME}@latest`;
|
|
3281
|
-
}
|
|
3282
2977
|
// src/global-install.ts
|
|
3283
2978
|
/** The name of the installed command. */
|
|
3284
2979
|
const BIN = "dstack";
|
|
@@ -3297,6 +2992,37 @@ function temporaryRunner(location = import.meta.url) {
|
|
|
3297
2992
|
if (path.includes("/dlx/") || path.includes("/dlx-")) return "pnpm";
|
|
3298
2993
|
if (path.includes("/bunx-")) return "bun";
|
|
3299
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
|
+
}
|
|
3300
3026
|
/** Whether `dstack` is on the `PATH`, leaving out the temporary directories npx and friends add. */
|
|
3301
3027
|
function isOnPath(env = process.env, platform = process.platform) {
|
|
3302
3028
|
const names = platform === "win32" ? [
|
|
@@ -3321,6 +3047,12 @@ function installCommand(runner = "npm") {
|
|
|
3321
3047
|
"--global",
|
|
3322
3048
|
`${PACKAGE_NAME}@latest`
|
|
3323
3049
|
];
|
|
3050
|
+
if (runner === "yarn") return [
|
|
3051
|
+
"yarn",
|
|
3052
|
+
"global",
|
|
3053
|
+
"add",
|
|
3054
|
+
`${PACKAGE_NAME}@latest`
|
|
3055
|
+
];
|
|
3324
3056
|
return [
|
|
3325
3057
|
"npm",
|
|
3326
3058
|
"i",
|
|
@@ -3375,7 +3107,7 @@ function explainFailure(runner, output) {
|
|
|
3375
3107
|
const last = output.trim().split("\n").pop()?.trim();
|
|
3376
3108
|
return `${last ? `${last} ` : ""}Run ${manual} to try again.`;
|
|
3377
3109
|
}
|
|
3378
|
-
function
|
|
3110
|
+
function runInstall(args) {
|
|
3379
3111
|
return new Promise((resolve) => {
|
|
3380
3112
|
const [cmd, ...rest] = args;
|
|
3381
3113
|
let output = "";
|
|
@@ -3433,7 +3165,7 @@ async function offerGlobalInstall() {
|
|
|
3433
3165
|
}
|
|
3434
3166
|
const spinner = p.spinner();
|
|
3435
3167
|
spinner.start(`Installing with ${command(args.join(" "))}`);
|
|
3436
|
-
const result = await
|
|
3168
|
+
const result = await runInstall(args);
|
|
3437
3169
|
if (!result.ok) {
|
|
3438
3170
|
saveInstallAnswer("failed");
|
|
3439
3171
|
spinner.error(`Couldn't install ${BIN}`);
|
|
@@ -4437,7 +4169,8 @@ function checkSteps(project, tests) {
|
|
|
4437
4169
|
}
|
|
4438
4170
|
/** `dstack check`: the doctor's checks, Django's checks, the linters and, with --tests, the tests. */
|
|
4439
4171
|
async function check(options) {
|
|
4440
|
-
const
|
|
4172
|
+
const base = open(options);
|
|
4173
|
+
const project = activeProject(base);
|
|
4441
4174
|
intro("Check");
|
|
4442
4175
|
let failed = 0;
|
|
4443
4176
|
const spinner = p.spinner();
|
|
@@ -4518,11 +4251,12 @@ async function update(options) {
|
|
|
4518
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/");
|
|
4519
4252
|
for (const target of targets) {
|
|
4520
4253
|
if (compose(target, ["pull", "--ignore-buildable"]) !== 0) fail("Pulling the images failed.");
|
|
4521
|
-
|
|
4254
|
+
const build = [
|
|
4522
4255
|
"build",
|
|
4523
4256
|
"--pull",
|
|
4524
4257
|
...options.cache ? [] : ["--no-cache"]
|
|
4525
|
-
]
|
|
4258
|
+
];
|
|
4259
|
+
if (compose(target, build) !== 0) fail("Building the images failed.");
|
|
4526
4260
|
}
|
|
4527
4261
|
const volume = project.prod ? void 0 : nodeModulesVolume(project);
|
|
4528
4262
|
if (volume) {
|
|
@@ -4593,12 +4327,184 @@ async function config(options) {
|
|
|
4593
4327
|
if (result.env === envText) p.outro(pc.green("Done"));
|
|
4594
4328
|
else p.outro(`Restart to apply it: ${command("dstack stop")}, then ${command("dstack dev")}`);
|
|
4595
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"));
|
|
4500
|
+
}
|
|
4596
4501
|
// src/program.ts
|
|
4597
|
-
/** 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. */
|
|
4598
4503
|
const QUIET_COMMANDS = /* @__PURE__ */ new Set([
|
|
4599
4504
|
"secret",
|
|
4600
4505
|
"complete",
|
|
4601
|
-
"completion"
|
|
4506
|
+
"completion",
|
|
4507
|
+
"self-update"
|
|
4602
4508
|
]);
|
|
4603
4509
|
/** A flag for each stack option, such as `--cache <cache>`. */
|
|
4604
4510
|
function stackFlags() {
|
|
@@ -4670,6 +4576,7 @@ function createProgram() {
|
|
|
4670
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);
|
|
4671
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);
|
|
4672
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);
|
|
4673
4580
|
program.command("secret").description("Print a random secret").argument("[bytes]", "The number of random bytes", positiveInteger, 32).action((bytes) => console.log(generateSecret(bytes)));
|
|
4674
4581
|
tab(program, { completionCommandName: "completion" });
|
|
4675
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)")}`);
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { P as DEFAULT_STACK, i as FRONTENDS, F as NAMED_PRESETS, L as STACK_OPTIONS, v as VERSION, o as cacheEnv, z as decodePreset, B as encodePreset, b as generateProjectSecrets, x as generateSecret, l as normalizeDatabaseUrl, et as parseEnv, V as pendingChoices, n as productionEnv, H as resolvePreset, Y as runDoctor, d as scaffold, nt as setEnvValues, p as toProjectName, h as usesValkey, r as validateDomain, g as validateProjectName } from "./production-CGkI7oNe.js";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
-
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { join, relative } from "node:path";
|
|
4
|
-
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
4
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
5
5
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { createConnection, createServer } from "node:net";
|
|
@@ -436,25 +436,6 @@ async function chooseFreePorts(env, profiles, isFree = isPortFree) {
|
|
|
436
436
|
function describeMove(move) {
|
|
437
437
|
return `Port ${move.from} is in use, so the ${move.label} uses ${move.to}.`;
|
|
438
438
|
}
|
|
439
|
-
// src/secrets.ts
|
|
440
|
-
/** A URL-safe random secret with `bytes` bytes of randomness. */
|
|
441
|
-
function generateSecret(bytes = 32) {
|
|
442
|
-
return randomBytes(bytes).toString("base64url");
|
|
443
|
-
}
|
|
444
|
-
/** The secrets a project's .env needs, by the name used in `{{secret:<name>}}`. */
|
|
445
|
-
function generateProjectSecrets() {
|
|
446
|
-
return {
|
|
447
|
-
django: generateSecret(48),
|
|
448
|
-
postgres: generateSecret(24),
|
|
449
|
-
api: generateSecret(32),
|
|
450
|
-
api_read: generateSecret(32),
|
|
451
|
-
preview: generateSecret(32),
|
|
452
|
-
revalidate: generateSecret(32),
|
|
453
|
-
admin: generateSecret(12),
|
|
454
|
-
umami_app: generateSecret(32),
|
|
455
|
-
umami_admin: generateSecret(24)
|
|
456
|
-
};
|
|
457
|
-
}
|
|
458
439
|
// src/stack.ts
|
|
459
440
|
const STACK_OPTIONS = [
|
|
460
441
|
{
|
|
@@ -865,9 +846,270 @@ function pendingChoices(stack) {
|
|
|
865
846
|
}];
|
|
866
847
|
});
|
|
867
848
|
}
|
|
849
|
+
// src/project.ts
|
|
850
|
+
/** The `.env` of `dstack run` on a development machine: the prod profile at https://site.localhost. */
|
|
851
|
+
const RUN_ENV = ".env.run";
|
|
852
|
+
const RUN_DOMAIN = "site.localhost";
|
|
853
|
+
/**
|
|
854
|
+
* The project's scripts that must be executable. npm and pnpm packages do
|
|
855
|
+
* not keep the executable bit of non-bin files, so the templates arrive
|
|
856
|
+
* without it.
|
|
857
|
+
*/
|
|
858
|
+
const EXECUTABLES = ["backend/entrypoint.sh", "backend/manage.py"];
|
|
859
|
+
/** Makes the project's scripts executable for everyone who can read them. */
|
|
860
|
+
function makeExecutable(directory) {
|
|
861
|
+
for (const file of EXECUTABLES) {
|
|
862
|
+
const path = join(directory, file);
|
|
863
|
+
try {
|
|
864
|
+
const { mode } = statSync(path);
|
|
865
|
+
const executable = mode | (mode & 292) >> 2;
|
|
866
|
+
if (executable !== mode) chmodSync(path, executable & 4095);
|
|
867
|
+
} catch {}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
/** The project directory: `start` or the nearest parent with `compose.yaml` and `.env`. */
|
|
871
|
+
function findProjectDirectory(start) {
|
|
872
|
+
let directory = resolve(start);
|
|
873
|
+
for (;;) {
|
|
874
|
+
if (existsSync(join(directory, "compose.yaml")) && existsSync(join(directory, ".env"))) return directory;
|
|
875
|
+
const parent = dirname(directory);
|
|
876
|
+
if (parent === directory) return void 0;
|
|
877
|
+
directory = parent;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
function parseProfiles(value) {
|
|
881
|
+
return (value ?? "").split(",").map((profile) => profile.trim()).filter(Boolean);
|
|
882
|
+
}
|
|
883
|
+
function projectName(directory) {
|
|
884
|
+
try {
|
|
885
|
+
const match = readFileSync(join(directory, "compose.yaml"), "utf8").match(/^name:\s*["']?([\w.-]+)/m);
|
|
886
|
+
if (match?.[1]) return match[1];
|
|
887
|
+
} catch {}
|
|
888
|
+
return basename(directory).toLowerCase();
|
|
889
|
+
}
|
|
890
|
+
function fromEnv(directory, name, envFile, localRun) {
|
|
891
|
+
const env = parseEnv(readFileSync(join(directory, envFile), "utf8"));
|
|
892
|
+
const profiles = parseProfiles(env.COMPOSE_PROFILES);
|
|
893
|
+
const prod = profiles.includes("prod");
|
|
894
|
+
const suffix = prod ? "-prod" : "";
|
|
895
|
+
return {
|
|
896
|
+
directory,
|
|
897
|
+
name: localRun ? `${name}-run` : name,
|
|
898
|
+
env,
|
|
899
|
+
profiles,
|
|
900
|
+
prod,
|
|
901
|
+
localRun,
|
|
902
|
+
services: {
|
|
903
|
+
backend: `backend${suffix}`,
|
|
904
|
+
frontend: `frontend${suffix}`,
|
|
905
|
+
worker: `worker${suffix}`
|
|
906
|
+
},
|
|
907
|
+
composeArgs: localRun ? [
|
|
908
|
+
"--env-file",
|
|
909
|
+
envFile,
|
|
910
|
+
"--project-name",
|
|
911
|
+
`${name}-run`
|
|
912
|
+
] : [],
|
|
913
|
+
composeEnv: localRun ? {
|
|
914
|
+
ENV_FILE: envFile,
|
|
915
|
+
COMPOSE_PROFILES: env.COMPOSE_PROFILES ?? ""
|
|
916
|
+
} : {}
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
/** Reads the project in or above `start`. Throws with a one-line reason when there is none. */
|
|
920
|
+
function readProject(start = ".") {
|
|
921
|
+
const directory = findProjectDirectory(start);
|
|
922
|
+
if (!directory) throw new Error(`No DjangoStack project in ${resolve(start)}: run this where compose.yaml and .env are.`);
|
|
923
|
+
makeExecutable(directory);
|
|
924
|
+
return fromEnv(directory, projectName(directory), ".env", false);
|
|
925
|
+
}
|
|
926
|
+
/** The local production run of a development project, when `.env.run` exists. */
|
|
927
|
+
function readLocalRun(project) {
|
|
928
|
+
if (project.prod || project.localRun || !existsSync(join(project.directory, ".env.run"))) return void 0;
|
|
929
|
+
return fromEnv(project.directory, projectName(project.directory), RUN_ENV, true);
|
|
930
|
+
}
|
|
931
|
+
/** The `django-stack` section of the project's `package.json`. */
|
|
932
|
+
function projectManifest(project) {
|
|
933
|
+
try {
|
|
934
|
+
return JSON.parse(readFileSync(join(project.directory, "package.json"), "utf8"))["django-stack"] ?? {};
|
|
935
|
+
} catch {
|
|
936
|
+
return {};
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* The project's stack: from the preset `init` saved in `package.json`, or,
|
|
941
|
+
* for projects from before that, the defaults with the storage from `.env`.
|
|
942
|
+
* Analytics follow the `analytics` profile: projects from before Umami was
|
|
943
|
+
* generated have the default in their preset without running it.
|
|
944
|
+
*/
|
|
945
|
+
function projectStack(project) {
|
|
946
|
+
const fromEnv = {
|
|
947
|
+
analytics: project.profiles.includes("analytics") ? "umami" : "off",
|
|
948
|
+
...project.env.CACHE_URL === void 0 ? {} : cacheStack(project.env)
|
|
949
|
+
};
|
|
950
|
+
const { preset } = projectManifest(project);
|
|
951
|
+
if (preset) try {
|
|
952
|
+
return {
|
|
953
|
+
...decodePreset(preset),
|
|
954
|
+
...fromEnv
|
|
955
|
+
};
|
|
956
|
+
} catch {}
|
|
957
|
+
return {
|
|
958
|
+
...DEFAULT_STACK,
|
|
959
|
+
...fromEnv,
|
|
960
|
+
storage: project.profiles.includes("s3") ? "s3" : "local"
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
const REDIS = /^(redis|rediss|valkey|valkeys):/;
|
|
964
|
+
/**
|
|
965
|
+
* The cache and the tasks a project's `.env` runs. Projects from before 0.7
|
|
966
|
+
* have no TASKS_URL: their tasks follow a Redis cache, like the backend does.
|
|
967
|
+
*/
|
|
968
|
+
function cacheStack(env) {
|
|
969
|
+
const cacheUrl = env.CACHE_URL ?? "";
|
|
970
|
+
const cache = cacheUrl.startsWith("db:") ? "database" : REDIS.test(cacheUrl) ? "valkey" : "off";
|
|
971
|
+
const tasksUrl = env.TASKS_URL || (cache === "valkey" ? cacheUrl : "immediate://");
|
|
972
|
+
if (tasksUrl.startsWith("immediate:")) return {
|
|
973
|
+
cache,
|
|
974
|
+
tasks: "off"
|
|
975
|
+
};
|
|
976
|
+
return {
|
|
977
|
+
cache,
|
|
978
|
+
tasks: REDIS.test(tasksUrl) && cache !== "valkey" ? "huey-valkey" : "huey"
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
/** The URLs of a running project. */
|
|
982
|
+
function projectUrls(project) {
|
|
983
|
+
if (project.prod) {
|
|
984
|
+
const site = (project.env.SITE_URL ?? "").replace(/\/$/, "");
|
|
985
|
+
return [{
|
|
986
|
+
label: "Site",
|
|
987
|
+
url: `${site}/`
|
|
988
|
+
}, {
|
|
989
|
+
label: "Admin",
|
|
990
|
+
url: `${site}/admin/`
|
|
991
|
+
}];
|
|
992
|
+
}
|
|
993
|
+
const ports = envPorts(project.env);
|
|
994
|
+
const site = (project.env.SITE_URL || `http://localhost:${ports.SITE_PORT}`).replace(/\/$/, "");
|
|
995
|
+
const urls = [
|
|
996
|
+
{
|
|
997
|
+
label: "Site",
|
|
998
|
+
url: site
|
|
999
|
+
},
|
|
1000
|
+
{
|
|
1001
|
+
label: "Admin",
|
|
1002
|
+
url: `${site}/admin/`
|
|
1003
|
+
},
|
|
1004
|
+
{
|
|
1005
|
+
label: "Mail",
|
|
1006
|
+
url: `http://localhost:${ports.MAIL_PORT}`
|
|
1007
|
+
}
|
|
1008
|
+
];
|
|
1009
|
+
if (project.profiles.includes("s3")) urls.push({
|
|
1010
|
+
label: "S3",
|
|
1011
|
+
url: `http://localhost:${ports.S3_PORT}`
|
|
1012
|
+
});
|
|
1013
|
+
return urls;
|
|
1014
|
+
}
|
|
1015
|
+
/** Runs a command with the terminal attached and returns its exit status. */
|
|
1016
|
+
const runInherit = (cmd, args, options) => {
|
|
1017
|
+
const result = spawnSync(cmd, args, {
|
|
1018
|
+
stdio: "inherit",
|
|
1019
|
+
...options
|
|
1020
|
+
});
|
|
1021
|
+
if (result.error) return 127;
|
|
1022
|
+
return result.status ?? 1;
|
|
1023
|
+
};
|
|
1024
|
+
/** The full `docker compose` argument list for a project. */
|
|
1025
|
+
function composeCommand(project, args) {
|
|
1026
|
+
return [
|
|
1027
|
+
"compose",
|
|
1028
|
+
...project.composeArgs,
|
|
1029
|
+
...args
|
|
1030
|
+
];
|
|
1031
|
+
}
|
|
1032
|
+
function composeOptions(project) {
|
|
1033
|
+
return {
|
|
1034
|
+
cwd: project.directory,
|
|
1035
|
+
env: {
|
|
1036
|
+
...process.env,
|
|
1037
|
+
...project.composeEnv
|
|
1038
|
+
}
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
/** Runs `docker compose <args>` for the project with the terminal attached. */
|
|
1042
|
+
function compose(project, args, run = runInherit) {
|
|
1043
|
+
return run("docker", composeCommand(project, args), composeOptions(project));
|
|
1044
|
+
}
|
|
1045
|
+
/** Runs `docker compose <args>` and captures the output. */
|
|
1046
|
+
function composeOutput(project, args) {
|
|
1047
|
+
const result = spawnSync("docker", composeCommand(project, args), {
|
|
1048
|
+
...composeOptions(project),
|
|
1049
|
+
encoding: "utf8",
|
|
1050
|
+
stdio: [
|
|
1051
|
+
"ignore",
|
|
1052
|
+
"pipe",
|
|
1053
|
+
"pipe"
|
|
1054
|
+
]
|
|
1055
|
+
});
|
|
1056
|
+
return {
|
|
1057
|
+
status: result.status ?? 1,
|
|
1058
|
+
output: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim()
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
/** The running services of the project. */
|
|
1062
|
+
function runningServices(project) {
|
|
1063
|
+
const result = spawnSync("docker", composeCommand(project, [
|
|
1064
|
+
"ps",
|
|
1065
|
+
"--services",
|
|
1066
|
+
"--filter",
|
|
1067
|
+
"status=running"
|
|
1068
|
+
]), {
|
|
1069
|
+
...composeOptions(project),
|
|
1070
|
+
encoding: "utf8",
|
|
1071
|
+
stdio: [
|
|
1072
|
+
"ignore",
|
|
1073
|
+
"pipe",
|
|
1074
|
+
"ignore"
|
|
1075
|
+
]
|
|
1076
|
+
});
|
|
1077
|
+
return result.status === 0 ? result.stdout.split("\n").filter(Boolean) : [];
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* The project a command acts on: the local production run when it is the
|
|
1081
|
+
* one running, otherwise the project `.env` describes.
|
|
1082
|
+
*/
|
|
1083
|
+
function activeProject(project) {
|
|
1084
|
+
const localRun = readLocalRun(project);
|
|
1085
|
+
if (!localRun) return project;
|
|
1086
|
+
if (runningServices(project).length) return project;
|
|
1087
|
+
return runningServices(localRun).length ? localRun : project;
|
|
1088
|
+
}
|
|
1089
|
+
// src/secrets.ts
|
|
1090
|
+
/** A URL-safe random secret with `bytes` bytes of randomness. */
|
|
1091
|
+
function generateSecret(bytes = 32) {
|
|
1092
|
+
return randomBytes(bytes).toString("base64url");
|
|
1093
|
+
}
|
|
1094
|
+
/** The secrets a project's .env needs, by the name used in `{{secret:<name>}}`. */
|
|
1095
|
+
function generateProjectSecrets() {
|
|
1096
|
+
return {
|
|
1097
|
+
django: generateSecret(48),
|
|
1098
|
+
postgres: generateSecret(24),
|
|
1099
|
+
api: generateSecret(32),
|
|
1100
|
+
api_read: generateSecret(32),
|
|
1101
|
+
preview: generateSecret(32),
|
|
1102
|
+
revalidate: generateSecret(32),
|
|
1103
|
+
admin: generateSecret(12),
|
|
1104
|
+
umami_app: generateSecret(32),
|
|
1105
|
+
umami_admin: generateSecret(24)
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
868
1108
|
// src/version.ts
|
|
869
1109
|
/** The version of this package, the same for every @django-stack package. */
|
|
870
1110
|
const CLI_VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
1111
|
+
/** The npm package of the CLI. */
|
|
1112
|
+
const PACKAGE_NAME = "@django-stack/cli";
|
|
871
1113
|
// src/scaffold.ts
|
|
872
1114
|
/** The frontends this version of the CLI generates. */
|
|
873
1115
|
const FRONTENDS = stackOption("frontend").values.filter((value) => value.generated).map((value) => value.value);
|
|
@@ -992,6 +1234,7 @@ function scaffold(options) {
|
|
|
992
1234
|
cpSync(join(templates, "backend"), join(target, "backend"), { recursive: true });
|
|
993
1235
|
cpSync(frontendTemplate, join(target, "frontend"), { recursive: true });
|
|
994
1236
|
renameSync(join(target, "gitignore"), join(target, ".gitignore"));
|
|
1237
|
+
makeExecutable(target);
|
|
995
1238
|
const about = join(target, "frontend", "template.json");
|
|
996
1239
|
const frontend = JSON.parse(readFileSync(about, "utf8"));
|
|
997
1240
|
rmSync(about);
|
|
@@ -1198,4 +1441,4 @@ function productionEnv(devEnv, options) {
|
|
|
1198
1441
|
todo
|
|
1199
1442
|
};
|
|
1200
1443
|
}
|
|
1201
|
-
export {
|
|
1444
|
+
export { isDockerDown as $, projectUrls as A, encodePreset as B, RUN_ENV as C, findProjectDirectory as D, composeOutput as E, NAMED_PRESETS as F, chooseFreePorts as G, resolvePreset as H, PROMPT_ORDER as I, portValues as J, describeMove as K, STACK_OPTIONS as L, readProject as M, runningServices as N, parseProfiles as O, DEFAULT_STACK as P, dockerRunning as Q, applies as R, RUN_DOMAIN as S, compose as T, stackOption as U, pendingChoices as V, DEV_PORTS as W, checkTools as X, runDoctor as Y, dockerNotRunning as Z, validateTimeZone as _, LOCAL_S3 as a, generateProjectSecrets as b, lockBackend as c, scaffold as d, parseEnv as et, timeZones as f, validateProjectName as g, usesValkey as h, FRONTENDS as i, readLocalRun as j, projectStack as k, normalizeDatabaseUrl as l, umamiEnv as m, productionEnv as n, setEnvValues as nt, cacheEnv as o, toProjectName as p, envPorts as q, validateDomain as r, localS3 as s, missingS3 as t, quoteEnvValue as tt, relativeDirectory as u, CLI_VERSION as v, activeProject as w, generateSecret as x, PACKAGE_NAME as y, decodePreset as z };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@django-stack/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "The production-ready Django, batteries included stack. Create and manage DjangoStack projects: a Django backend, a frontend, Postgres and Docker Compose in one command.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Leon van der Grient",
|
|
@@ -25,10 +25,12 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
|
|
25
25
|
|
|
26
26
|
# Development runs as root, so files the container writes into the mounted
|
|
27
27
|
# source (such as new migrations) work on every host. Production does not.
|
|
28
|
+
# The entrypoint runs with sh, so it works without the executable bit (the
|
|
29
|
+
# mounted source keeps the host's file modes).
|
|
28
30
|
FROM deps AS dev
|
|
29
31
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
|
30
32
|
uv sync --no-install-project
|
|
31
|
-
ENTRYPOINT ["/app/entrypoint.sh"]
|
|
33
|
+
ENTRYPOINT ["sh", "/app/entrypoint.sh"]
|
|
32
34
|
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
|
33
35
|
|
|
34
36
|
FROM base AS prod
|
|
@@ -41,5 +43,5 @@ USER app
|
|
|
41
43
|
EXPOSE 8000
|
|
42
44
|
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=5 \
|
|
43
45
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/readyz', timeout=4)"
|
|
44
|
-
ENTRYPOINT ["/app/entrypoint.sh"]
|
|
46
|
+
ENTRYPOINT ["sh", "/app/entrypoint.sh"]
|
|
45
47
|
CMD ["gunicorn", "config.asgi:application", "--worker-class", "uvicorn_worker.UvicornWorker", "--bind", "0.0.0.0:8000", "--workers", "3", "--access-logfile", "-", "--no-control-socket"]
|
|
@@ -5,7 +5,7 @@ description = "The Django backend of {{project_name}}."
|
|
|
5
5
|
requires-python = ">=3.12"
|
|
6
6
|
dependencies = [
|
|
7
7
|
"Django>=6.1,<6.2",
|
|
8
|
-
"django-stack[server,s3]>=0.
|
|
8
|
+
"django-stack[server,s3]>=0.8.1",
|
|
9
9
|
"django-headless[schema]>=1.1.1",
|
|
10
10
|
"django-content-studio[images,huey]>=1.11.0",
|
|
11
11
|
]
|
|
@@ -14,7 +14,10 @@ RUN --mount=type=cache,target=/root/.npm \
|
|
|
14
14
|
|
|
15
15
|
FROM deps AS dev
|
|
16
16
|
EXPOSE 3000
|
|
17
|
-
|
|
17
|
+
# A container runs one dev server, so a lock file in the mounted .astro/ is
|
|
18
|
+
# always stale: from a stopped container, whose process IDs the new one reuses,
|
|
19
|
+
# or from `npm run dev` on the host. Astro would refuse to start next to it.
|
|
20
|
+
CMD ["sh", "-c", "rm -f .astro/dev.json && exec npm run dev"]
|
|
18
21
|
|
|
19
22
|
FROM deps AS build
|
|
20
23
|
COPY . .
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"@django-headless/client": "^1.1.1",
|
|
16
|
-
"@django-stack/next": "^0.
|
|
17
|
-
"@django-stack/react": "^0.
|
|
18
|
-
"@django-stack/utils": "^0.
|
|
16
|
+
"@django-stack/next": "^0.8.1",
|
|
17
|
+
"@django-stack/react": "^0.8.1",
|
|
18
|
+
"@django-stack/utils": "^0.8.1",
|
|
19
19
|
"next": "^16.4.4",
|
|
20
20
|
"react": "^19.3.1",
|
|
21
21
|
"react-dom": "^19.3.1",
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"@django-headless/client": "^1.1.1",
|
|
18
|
-
"@django-stack/utils": "^0.
|
|
19
|
-
"@django-stack/vue": "^0.
|
|
18
|
+
"@django-stack/utils": "^0.8.1",
|
|
19
|
+
"@django-stack/vue": "^0.8.1",
|
|
20
20
|
"nuxt": "^4.6.3",
|
|
21
21
|
"nuxt-security": "^2.7.0",
|
|
22
22
|
"vue": "^3.5.44",
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@django-headless/client": "^1.1.1",
|
|
22
|
-
"@django-stack/svelte": "^0.
|
|
23
|
-
"@django-stack/utils": "^0.
|
|
22
|
+
"@django-stack/svelte": "^0.8.1",
|
|
23
|
+
"@django-stack/utils": "^0.8.1"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@sveltejs/adapter-node": "^6.0.1",
|