@django-stack/cli 0.8.1 → 0.10.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.
Files changed (50) hide show
  1. package/dist/bin.js +840 -54
  2. package/dist/index.d.ts +43 -4
  3. package/dist/index.js +1 -1
  4. package/dist/{production-CGkI7oNe.js → production-Ct_qoeXk.js} +210 -7
  5. package/package.json +1 -1
  6. package/templates/backend/Dockerfile +4 -5
  7. package/templates/backend/config/settings.py +3 -0
  8. package/templates/backend/config/studio.py +4 -0
  9. package/templates/backend/content/apps.py +5 -1
  10. package/templates/backend/content/models.py +2 -2
  11. package/templates/backend/content/notifications.py +36 -0
  12. package/templates/backend/content/tests/test_api.py +32 -2
  13. package/templates/backend/pyproject.toml +1 -1
  14. package/templates/base/AGENTS.md +8 -8
  15. package/templates/base/Caddyfile +7 -1
  16. package/templates/base/README.md +3 -3
  17. package/templates/base/compose.yaml +37 -2
  18. package/templates/base/env.example +29 -0
  19. package/templates/frontend-astro/AGENTS.md +3 -3
  20. package/templates/frontend-astro/Dockerfile +7 -4
  21. package/templates/frontend-astro/astro.config.mjs +16 -5
  22. package/templates/frontend-astro/package.json +1 -1
  23. package/templates/frontend-astro/pnpm-workspace.yaml +17 -0
  24. package/templates/frontend-astro/src/components/ContactForm.astro +19 -3
  25. package/templates/frontend-astro/src/middleware.ts +9 -1
  26. package/templates/frontend-next/AGENTS.md +3 -3
  27. package/templates/frontend-next/Dockerfile +6 -3
  28. package/templates/frontend-next/package.json +3 -3
  29. package/templates/frontend-next/pnpm-workspace.yaml +11 -0
  30. package/templates/frontend-next/src/app/[...path]/page.tsx +2 -1
  31. package/templates/frontend-next/src/components/ContactForm.tsx +14 -3
  32. package/templates/frontend-next/src/instrumentation.ts +12 -0
  33. package/templates/frontend-next/src/proxy.ts +4 -1
  34. package/templates/frontend-nuxt/AGENTS.md +3 -3
  35. package/templates/frontend-nuxt/Dockerfile +6 -3
  36. package/templates/frontend-nuxt/nuxt.config.ts +7 -3
  37. package/templates/frontend-nuxt/package.json +2 -2
  38. package/templates/frontend-nuxt/pnpm-workspace.yaml +13 -0
  39. package/templates/frontend-nuxt/server/plugins/errors.ts +14 -0
  40. package/templates/frontend-nuxt/server/routes/_content/layout.get.ts +4 -0
  41. package/templates/frontend-nuxt/src/components/ContactForm.vue +15 -2
  42. package/templates/frontend-sveltekit/AGENTS.md +3 -3
  43. package/templates/frontend-sveltekit/Dockerfile +7 -4
  44. package/templates/frontend-sveltekit/package.json +2 -2
  45. package/templates/frontend-sveltekit/pnpm-workspace.yaml +11 -0
  46. package/templates/frontend-sveltekit/src/hooks.server.ts +13 -1
  47. package/templates/frontend-sveltekit/src/lib/components/ContactForm.svelte +15 -2
  48. package/templates/frontend-sveltekit/src/routes/+layout.server.ts +3 -0
  49. package/templates/frontend-sveltekit/src/routes/[...path]/+page.svelte +1 -1
  50. package/templates/frontend-sveltekit/vite.config.ts +10 -3
package/dist/bin.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
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";
2
+ import { $ as envPorts, A as compose, B as NAMED_PRESETS, C as PACKAGE_NAME, D as RUN_DOMAIN, E as CAPTCHA_APP, F as projectUrls, H as PROMPT_ORDER, I as readLocalRun, J as resolvePreset, K as encodePreset, L as readProject, M as findProjectDirectory, N as parseProfiles, O as RUN_ENV, P as projectStack, Q as describeMove, R as runningServices, S as CLI_VERSION, T as generateSecret, U as STACK_OPTIONS, V as NODE_FRONTENDS, W as applies, X as DEV_PORTS, Y as stackOption, Z as chooseFreePorts, _ as umamiEnv, a as LOCAL_S3, at as isDockerDown, b as validateProjectName, c as localS3, ct as setEnvValues, et as portValues, f as relativeDirectory, g as toProjectName, h as timeZones, it as dockerRunning, j as composeOutput, k as activeProject, l as lockBackend, m as setNotificationsApp, n as productionEnv$1, nt as checkTools, o as cacheEnv, ot as parseEnv, p as scaffold, q as pendingChoices, r as validateDomain, rt as dockerNotRunning, s as frontendPackageManager, st as quoteEnvValue, t as missingS3, tt as runDoctor, u as lockFrontend, v as updateFrontendLock, w as generateProjectSecrets, x as validateTimeZone, y as usesValkey, z as DEFAULT_STACK } from "./production-Ct_qoeXk.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, relative, resolve } from "node:path";
5
+ import { basename, delimiter, dirname, join, posix, relative, resolve, sep } from "node:path";
6
6
  import { execFileSync, spawn, spawnSync } from "node:child_process";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import pc from "picocolors";
@@ -19,7 +19,7 @@ const DROPPED_VOLUMES = /* @__PURE__ */ new Set([
19
19
  "media"
20
20
  ]);
21
21
  /** Compose keys that do not apply on Bunny: its gVisor sandbox isolates the app instead. */
22
- const IGNORED_KEYS = [
22
+ const IGNORED_KEYS$1 = [
23
23
  "read_only",
24
24
  "tmpfs",
25
25
  "cap_add",
@@ -81,15 +81,15 @@ function parsePublicImage(reference) {
81
81
  function repositoryName(value) {
82
82
  return value.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, "") || "app";
83
83
  }
84
- function escapeRegExp(value) {
84
+ function escapeRegExp$1(value) {
85
85
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
86
86
  }
87
87
  /** Matches `name:port` where name is a whole host name, such as backend:8000 in http://backend:8000/api. */
88
- function hostPattern(name) {
89
- return new RegExp(`(^|[^\\w.-])${escapeRegExp(name)}:(\\d{1,5})(?!\\d)`, "g");
88
+ function hostPattern$1(name) {
89
+ return new RegExp(`(^|[^\\w.-])${escapeRegExp$1(name)}:(\\d{1,5})(?!\\d)`, "g");
90
90
  }
91
91
  /** The names other services reach a service by: its name and its network aliases. */
92
- function hostNames(service, definition) {
92
+ function hostNames$1(service, definition) {
93
93
  const names = /* @__PURE__ */ new Set([service]);
94
94
  for (const network of Object.values(definition.networks ?? {})) for (const alias of network?.aliases ?? []) names.add(alias);
95
95
  return [...names];
@@ -97,7 +97,7 @@ function hostNames(service, definition) {
97
97
  /** Replaces service host names with 127.0.0.1, which is the pod on Bunny. */
98
98
  function rewriteHosts(text, names) {
99
99
  let result = text;
100
- for (const name of names) result = result.replace(hostPattern(name), (_whole, before, port) => `${before}127.0.0.1:${port}`);
100
+ for (const name of names) result = result.replace(hostPattern$1(name), (_whole, before, port) => `${before}127.0.0.1:${port}`);
101
101
  return result;
102
102
  }
103
103
  function containerName(service, taken) {
@@ -140,7 +140,7 @@ function planApp(project, options) {
140
140
  const entries = Object.entries(project.services).filter(([, definition]) => definition.deploy?.replicas !== 0).sort(([a], [b]) => a.localeCompare(b));
141
141
  if (!entries.length) throw new Error("compose.yaml has no services for production.");
142
142
  const names = /* @__PURE__ */ new Map();
143
- for (const [service, definition] of entries) names.set(service, hostNames(service, definition));
143
+ for (const [service, definition] of entries) names.set(service, hostNames$1(service, definition));
144
144
  const allNames = [...names.values()].flat();
145
145
  const builds = /* @__PURE__ */ new Map();
146
146
  const baked = /* @__PURE__ */ new Map();
@@ -178,7 +178,7 @@ function planApp(project, options) {
178
178
  const portsOf = /* @__PURE__ */ new Map();
179
179
  for (const [service, definition] of entries) {
180
180
  const found = /* @__PURE__ */ new Set();
181
- for (const name of names.get(service) ?? []) for (const text of texts) for (const match of text.matchAll(hostPattern(name))) found.add(Number(match[2]));
181
+ for (const name of names.get(service) ?? []) for (const text of texts) for (const match of text.matchAll(hostPattern$1(name))) found.add(Number(match[2]));
182
182
  const own = definition.healthcheck?.test ? healthUrl(definition.healthcheck.test) : void 0;
183
183
  if (own) found.add(own.port);
184
184
  for (const port of definition.ports ?? []) found.add(port.target);
@@ -212,7 +212,7 @@ function planApp(project, options) {
212
212
  const endpointPort = entryPorts.includes(80) ? 80 : entryPorts[0];
213
213
  const rewrite = (text) => {
214
214
  let result = text;
215
- for (const [service, move] of moved) for (const name of names.get(service) ?? []) result = result.replace(hostPattern(name), (whole, before, port) => Number(port) === move.from ? `${before}${name}:${move.to}` : whole);
215
+ for (const [service, move] of moved) for (const name of names.get(service) ?? []) result = result.replace(hostPattern$1(name), (whole, before, port) => Number(port) === move.from ? `${before}${name}:${move.to}` : whole);
216
216
  return rewriteHosts(result, allNames);
217
217
  };
218
218
  for (const build of builds.values()) if (build.kind === "bake") for (const file of build.files) file.content = rewrite(file.content);
@@ -220,7 +220,7 @@ function planApp(project, options) {
220
220
  const taken = /* @__PURE__ */ new Set();
221
221
  const containers = [];
222
222
  for (const [service, definition] of entries) {
223
- for (const key of IGNORED_KEYS) if (definition[key] !== void 0 && definition[key] !== null) ignored.add(key);
223
+ for (const key of IGNORED_KEYS$1) if (definition[key] !== void 0 && definition[key] !== null) ignored.add(key);
224
224
  let image;
225
225
  const bake = baked.get(service);
226
226
  if (bake) image = {
@@ -883,6 +883,40 @@ const systemRunner = (command, args, options = {}) => new Promise((resolvePromis
883
883
  child.stdin.on("error", () => {});
884
884
  child.stdin.end(options.input);
885
885
  });
886
+ /** The production Compose project, resolved with the variables, as JSON. */
887
+ async function resolveCompose(run, directory, env) {
888
+ const folder = mkdtempSync(join(tmpdir(), "dstack-env-"));
889
+ const file = join(folder, ".env");
890
+ writeFileSync(file, `${Object.entries(env).map(([key, value]) => `${key}=${envValue(value)}`).join("\n")}\n`, { mode: 384 });
891
+ const args = [
892
+ "compose",
893
+ "--env-file",
894
+ file,
895
+ "config",
896
+ "--format",
897
+ "json"
898
+ ];
899
+ try {
900
+ const result = await run("docker", args, {
901
+ cwd: directory,
902
+ env: {
903
+ ...env,
904
+ ENV_FILE: file
905
+ }
906
+ });
907
+ if (result.code !== 0) {
908
+ if (isDockerDown(`${result.stderr}\n${result.stdout}`)) throw new Error(dockerNotRunning());
909
+ const last = (result.stderr.trim() || result.stdout.trim()).split("\n").at(-1) ?? "";
910
+ throw new Error(`docker compose failed (exit ${result.code})${last ? `: ${last}` : "."}`);
911
+ }
912
+ return JSON.parse(result.stdout);
913
+ } finally {
914
+ rmSync(folder, {
915
+ recursive: true,
916
+ force: true
917
+ });
918
+ }
919
+ }
886
920
  var BunnyError = class extends Error {
887
921
  status;
888
922
  constructor(message, status) {
@@ -1186,31 +1220,7 @@ var BunnyClient = class {
1186
1220
  }
1187
1221
  /** The production Compose project, resolved with the variables, as JSON. */
1188
1222
  async composeConfig(directory, env) {
1189
- const folder = mkdtempSync(join(tmpdir(), "dstack-env-"));
1190
- const file = join(folder, ".env");
1191
- writeFileSync(file, `${Object.entries(env).map(([key, value]) => `${key}=${envValue(value)}`).join("\n")}\n`, { mode: 384 });
1192
- try {
1193
- const output = await this.docker([
1194
- "compose",
1195
- "--env-file",
1196
- file,
1197
- "config",
1198
- "--format",
1199
- "json"
1200
- ], {
1201
- cwd: directory,
1202
- env: {
1203
- ...env,
1204
- ENV_FILE: file
1205
- }
1206
- });
1207
- return JSON.parse(output);
1208
- } finally {
1209
- rmSync(folder, {
1210
- recursive: true,
1211
- force: true
1212
- });
1213
- }
1223
+ return resolveCompose(this.run, directory, env);
1214
1224
  }
1215
1225
  async plan(directory, env) {
1216
1226
  return planApp(await this.composeConfig(directory, env), { readFile: this.readFile });
@@ -1919,7 +1929,7 @@ function toDeployment(api, link, item) {
1919
1929
  };
1920
1930
  }
1921
1931
  /** Finds a name or uuid in a list. */
1922
- function find$1(items, wanted) {
1932
+ function find$2(items, wanted) {
1923
1933
  return items.find((item) => item.uuid === wanted) ?? items.find((item) => item.name === wanted);
1924
1934
  }
1925
1935
  var CoolifyClient = class {
@@ -1940,10 +1950,10 @@ var CoolifyClient = class {
1940
1950
  if (!servers.length) throw new Error("This Coolify has no servers. Add one in Coolify first.");
1941
1951
  let server;
1942
1952
  if (options.server) {
1943
- server = find$1(servers, options.server);
1953
+ server = find$2(servers, options.server);
1944
1954
  if (!server) throw new Error(`Coolify has no server ${options.server}.`);
1945
1955
  } else if (servers.length === 1) server = servers[0];
1946
- else server = find$1(servers, await ask.select("Server to deploy to", servers.map((item) => ({
1956
+ else server = find$2(servers, await ask.select("Server to deploy to", servers.map((item) => ({
1947
1957
  value: item.uuid,
1948
1958
  label: item.name,
1949
1959
  hint: item.ip
@@ -1952,7 +1962,7 @@ var CoolifyClient = class {
1952
1962
  const projects = await api.get("/projects");
1953
1963
  let project;
1954
1964
  const projectName = options.project ?? context.name;
1955
- project = find$1(projects, projectName);
1965
+ project = find$2(projects, projectName);
1956
1966
  if (!options.project && ask.interactive && projects.length) {
1957
1967
  const NEW = "";
1958
1968
  const uuid = await ask.select("Coolify project", [...project ? [] : [{
@@ -1963,7 +1973,7 @@ var CoolifyClient = class {
1963
1973
  label: item.name,
1964
1974
  hint: item.description ?? void 0
1965
1975
  }))], project?.uuid ?? NEW);
1966
- project = uuid === NEW ? void 0 : find$1(projects, uuid);
1976
+ project = uuid === NEW ? void 0 : find$2(projects, uuid);
1967
1977
  }
1968
1978
  if (!project) {
1969
1979
  if (options.project && /^[a-z0-9]{20,}$/.test(options.project)) throw new Error(`Coolify has no project ${options.project}.`);
@@ -1977,7 +1987,7 @@ var CoolifyClient = class {
1977
1987
  ask.step(`Created the Coolify project ${projectName}`);
1978
1988
  }
1979
1989
  const environmentName = options.environment ?? "production";
1980
- let environment = find$1(await api.get(`/projects/${enc(project.uuid)}/environments`), environmentName);
1990
+ let environment = find$2(await api.get(`/projects/${enc(project.uuid)}/environments`), environmentName);
1981
1991
  if (!environment) {
1982
1992
  environment = {
1983
1993
  uuid: (await api.post(`/projects/${enc(project.uuid)}/environments`, { name: environmentName })).uuid,
@@ -1987,7 +1997,7 @@ var CoolifyClient = class {
1987
1997
  }
1988
1998
  let githubApp;
1989
1999
  if (options.githubApp && options.githubApp !== "public") {
1990
- const app = find$1(await api.get("/github-apps"), options.githubApp);
2000
+ const app = find$2(await api.get("/github-apps"), options.githubApp);
1991
2001
  if (!app) throw new Error(`Coolify has no GitHub App ${options.githubApp}.`);
1992
2002
  githubApp = app.uuid;
1993
2003
  } else if (!options.githubApp && ask.interactive) {
@@ -2180,7 +2190,7 @@ function composeUrl(api, link) {
2180
2190
  return `${api.url}/dashboard/project/${link.project}/environment/${link.environment}/services/compose/${link.application}`;
2181
2191
  }
2182
2192
  /** Finds a name or id in a list. */
2183
- function find(items, wanted, id, name) {
2193
+ function find$1(items, wanted, id, name) {
2184
2194
  return items.find((item) => id(item) === wanted) ?? items.find((item) => name(item) === wanted);
2185
2195
  }
2186
2196
  const LOCAL_SERVER = "dokploy";
@@ -2201,7 +2211,7 @@ var DokployClient = class {
2201
2211
  const servers = await api.get("server.all").catch(() => []) ?? [];
2202
2212
  let serverId;
2203
2213
  if (options.server && options.server !== LOCAL_SERVER) {
2204
- const server = find(servers, options.server, (item) => item.serverId, (item) => item.name);
2214
+ const server = find$1(servers, options.server, (item) => item.serverId, (item) => item.name);
2205
2215
  if (!server) throw new Error(`Dokploy has no server ${options.server}.`);
2206
2216
  serverId = server.serverId;
2207
2217
  } else if (!options.server && servers.length && ask.interactive) {
@@ -2217,7 +2227,7 @@ var DokployClient = class {
2217
2227
  }
2218
2228
  const projects = await api.get("project.all") ?? [];
2219
2229
  const projectName = options.project ?? context.name;
2220
- let project = find(projects, projectName, (item) => item.projectId, (item) => item.name);
2230
+ let project = find$1(projects, projectName, (item) => item.projectId, (item) => item.name);
2221
2231
  if (!options.project && ask.interactive && projects.length) {
2222
2232
  const NEW = "";
2223
2233
  const id = await ask.select("Dokploy project", [...project ? [] : [{
@@ -2228,7 +2238,7 @@ var DokployClient = class {
2228
2238
  label: item.name,
2229
2239
  hint: item.description ?? void 0
2230
2240
  }))], project?.projectId ?? NEW);
2231
- project = id === NEW ? void 0 : find(projects, id, (item) => item.projectId, (item) => item.name);
2241
+ project = id === NEW ? void 0 : find$1(projects, id, (item) => item.projectId, (item) => item.name);
2232
2242
  }
2233
2243
  if (!project) {
2234
2244
  const created = await api.post("project.create", {
@@ -2244,7 +2254,7 @@ var DokployClient = class {
2244
2254
  ask.step(`Created the Dokploy project ${projectName}`);
2245
2255
  }
2246
2256
  const environmentName = options.environment ?? "production";
2247
- let environment = find(await api.get("environment.byProjectId", { projectId: project.projectId }).catch((error) => {
2257
+ let environment = find$1(await api.get("environment.byProjectId", { projectId: project.projectId }).catch((error) => {
2248
2258
  if (error.status === 404) throw new Error("This Dokploy has no environments; update Dokploy to a version with environments.");
2249
2259
  throw error;
2250
2260
  }) ?? [], environmentName, (item) => item.environmentId, (item) => item.name);
@@ -2380,6 +2390,726 @@ const dokploy = {
2380
2390
  return new DokployClient(url, token);
2381
2391
  }
2382
2392
  };
2393
+ // src/deploy/railway-compose.ts
2394
+ /**
2395
+ * Turns the resolved Compose project (`docker compose config --format json`)
2396
+ * into Railway services. Railway runs each service on its own and reaches it
2397
+ * at `<name>.railway.internal` on the private network; a service has at most
2398
+ * one volume and no bind mounts, and Railway builds a service from a
2399
+ * directory of the git repository. This module is pure, so it is tested on
2400
+ * the template's output.
2401
+ */
2402
+ /** Volumes not needed on Railway: its edge holds the certificate, uploads and backups go to S3. */
2403
+ const RAILWAY_DROPPED_VOLUMES = /* @__PURE__ */ new Set([
2404
+ "caddy_data",
2405
+ "caddy_config",
2406
+ "backups",
2407
+ "media"
2408
+ ]);
2409
+ /** Compose keys that do not apply on Railway. */
2410
+ const IGNORED_KEYS = [
2411
+ "read_only",
2412
+ "tmpfs",
2413
+ "cap_add",
2414
+ "cap_drop",
2415
+ "security_opt",
2416
+ "user",
2417
+ "restart",
2418
+ "depends_on"
2419
+ ];
2420
+ /** Railway's Postgres image, which its own Postgres template runs. */
2421
+ const RAILWAY_POSTGRES_IMAGE = "ghcr.io/railwayapp-templates/postgres-ssl";
2422
+ /**
2423
+ * The default command of images whose bind-mounted file the CLI writes before
2424
+ * it starts them; Railway's start command replaces the image's command.
2425
+ */
2426
+ const IMAGE_COMMANDS = [{
2427
+ pattern: /^(docker\.io\/)?(library\/)?caddy(:|@|$)/,
2428
+ command: [
2429
+ "caddy",
2430
+ "run",
2431
+ "--config",
2432
+ "/etc/caddy/Caddyfile",
2433
+ "--adapter",
2434
+ "caddyfile"
2435
+ ]
2436
+ }];
2437
+ function escapeRegExp(value) {
2438
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2439
+ }
2440
+ /** Matches `name:port` where name is a whole host name, such as backend:8000 in http://backend:8000/api. */
2441
+ function hostPattern(name) {
2442
+ return new RegExp(`(^|[^\\w.-])${escapeRegExp(name)}:(\\d{1,5})(?!\\d)`, "g");
2443
+ }
2444
+ /** Replaces Compose host names with the private host names of the Railway services. */
2445
+ function railwayHosts(text, hosts) {
2446
+ let result = text;
2447
+ for (const [name, service] of Object.entries(hosts)) result = result.replace(hostPattern(name), (_whole, before, port) => `${before}${service}.railway.internal:${port}`);
2448
+ return result;
2449
+ }
2450
+ /** A Railway service name: lower case letters, digits and dashes. */
2451
+ function railwayName(service) {
2452
+ return service.replace(/-prod$/, "").toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32) || "service";
2453
+ }
2454
+ /** Quotes a word of a start command only when it needs it. */
2455
+ function commandWord(value) {
2456
+ if (/^[\w@%+=:,./-]+$/.test(value)) return value;
2457
+ return `'${value.replace(/'/g, `'\\''`)}'`;
2458
+ }
2459
+ /** The variable that holds a bind-mounted file, such as DSTACK_FILE_CADDYFILE. */
2460
+ function fileVariable(target) {
2461
+ return `DSTACK_FILE_${(posix.basename(target) || "file").toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`;
2462
+ }
2463
+ /** The last stage of a Dockerfile, which Railway builds. */
2464
+ function lastStage(dockerfile) {
2465
+ let stage;
2466
+ for (const match of dockerfile.matchAll(/^\s*FROM\s+(?:--\S+\s+)*\S+(?:\s+AS\s+(\S+))?\s*$/gim)) stage = match[1]?.toLowerCase() ?? "";
2467
+ return stage;
2468
+ }
2469
+ function healthPath(definition) {
2470
+ if (definition.healthcheck?.disable) return void 0;
2471
+ const match = (definition.healthcheck?.test ?? []).join(" ").match(/https?:\/\/(?:localhost|127\.0\.0\.1)(?::(\d+))?(\/[^\s'"]*)?/);
2472
+ if (!match) return void 0;
2473
+ return {
2474
+ port: Number(match[1] ?? 80),
2475
+ path: match[2] ?? "/"
2476
+ };
2477
+ }
2478
+ /** The names other services reach a service by: its name and its network aliases. */
2479
+ function hostNames(service, definition) {
2480
+ const names = /* @__PURE__ */ new Set([service]);
2481
+ for (const network of Object.values(definition.networks ?? {})) for (const alias of network?.aliases ?? []) names.add(alias);
2482
+ return [...names];
2483
+ }
2484
+ /** Plans the Railway services for a resolved Compose project. */
2485
+ function planRailway(project, options) {
2486
+ const entries = Object.entries(project.services).filter(([, definition]) => definition.deploy?.replicas !== 0).sort(([a], [b]) => a.localeCompare(b));
2487
+ if (!entries.length) throw new Error("compose.yaml has no services for production.");
2488
+ const names = /* @__PURE__ */ new Map();
2489
+ const taken = /* @__PURE__ */ new Set();
2490
+ for (const [service] of entries) {
2491
+ let name = railwayName(service);
2492
+ for (let index = 2; taken.has(name); index++) name = `${railwayName(service).slice(0, 29)}-${index}`;
2493
+ taken.add(name);
2494
+ names.set(service, name);
2495
+ }
2496
+ const hosts = {};
2497
+ for (const [service, definition] of entries) for (const host of hostNames(service, definition)) hosts[host] = names.get(service);
2498
+ const texts = [];
2499
+ for (const [, definition] of entries) for (const value of Object.values(definition.environment ?? {})) if (value) texts.push(value);
2500
+ const files = /* @__PURE__ */ new Map();
2501
+ for (const [service, definition] of entries) {
2502
+ const binds = (definition.volumes ?? []).filter((mount) => mount.type === "bind");
2503
+ if (!binds.length) continue;
2504
+ files.set(service, binds.map((mount) => {
2505
+ let content;
2506
+ try {
2507
+ content = options.readFile(mount.source ?? "");
2508
+ } catch {
2509
+ throw new Error(`${service} bind-mounts ${mount.source}, which is not a file; Railway has no bind mounts, so only files can be passed to the service.`);
2510
+ }
2511
+ texts.push(content);
2512
+ return {
2513
+ target: mount.target,
2514
+ content
2515
+ };
2516
+ }));
2517
+ }
2518
+ const published = entries.filter(([, definition]) => (definition.ports ?? []).length);
2519
+ if (published.length !== 1) throw new Error(published.length ? `${published.map(([service]) => service).join(" and ")} publish ports; Railway gives the site one domain, so only the proxy may.` : "No service publishes a port, so there is nothing for Railway's domain to send requests to.");
2520
+ const publicService = published[0]?.[0];
2521
+ const s3 = entries.some(([, definition]) => Boolean(definition.environment?.S3_BUCKET));
2522
+ const ignored = /* @__PURE__ */ new Set();
2523
+ const services = [];
2524
+ for (const [service, definition] of entries) {
2525
+ const name = names.get(service);
2526
+ for (const key of IGNORED_KEYS) if (definition[key] !== void 0 && definition[key] !== null) ignored.add(key);
2527
+ if (definition.deploy?.resources) ignored.add("deploy.resources");
2528
+ const found = /* @__PURE__ */ new Set();
2529
+ for (const host of hostNames(service, definition)) for (const text of texts) for (const match of text.matchAll(hostPattern(host))) found.add(Number(match[2]));
2530
+ const health = healthPath(definition);
2531
+ if (health) found.add(health.port);
2532
+ const ports = (definition.ports ?? []).map((port) => port.target);
2533
+ let port;
2534
+ if (service === publicService) port = ports.includes(80) ? 80 : ports[0];
2535
+ else if (found.size > 1) throw new Error(`${service} is reached on ports ${[...found].join(" and ")}; Railway routes one port per service.`);
2536
+ else port = [...found][0];
2537
+ const env = {};
2538
+ for (const [key, value] of Object.entries(definition.environment ?? {})) {
2539
+ if (value === null || value === void 0 || key === "COMPOSE_PROFILES" || key === "ENV_FILE") continue;
2540
+ env[key] = railwayHosts(value, hosts);
2541
+ }
2542
+ if (port !== void 0 && !env.PORT) env.PORT = String(port);
2543
+ let source;
2544
+ let watchPatterns;
2545
+ if (definition.build) {
2546
+ const context = relative(options.directory, definition.build.context).split(sep).join("/");
2547
+ if (context.startsWith("..")) throw new Error(`${service} builds ${definition.build.context}, outside the project.`);
2548
+ const dockerfile = definition.build.dockerfile ?? "Dockerfile";
2549
+ const target = definition.build.target;
2550
+ if (target) {
2551
+ let text;
2552
+ try {
2553
+ text = options.readFile(posix.join(definition.build.context, dockerfile));
2554
+ } catch {}
2555
+ const last = text === void 0 ? void 0 : lastStage(text);
2556
+ if (last !== void 0 && last !== target.toLowerCase()) throw new Error(`${service} builds the ${target} stage of ${context || "."}/${dockerfile}, and Railway builds the last stage only. Make ${target} the last stage.`);
2557
+ }
2558
+ for (const [key, value] of Object.entries(definition.build.args ?? {})) if (env[key] === void 0 && value != null) env[key] = String(value);
2559
+ const rootDirectory = `/${context}`.replace(/\/$/, "") || "/";
2560
+ source = {
2561
+ kind: "repo",
2562
+ rootDirectory,
2563
+ ...dockerfile !== "Dockerfile" ? { dockerfile } : {}
2564
+ };
2565
+ watchPatterns = [rootDirectory === "/" ? "/**" : `${rootDirectory}/**`];
2566
+ } else if (definition.image) {
2567
+ let image = definition.image;
2568
+ const postgres = image.match(/^(?:docker\.io\/)?(?:library\/)?postgres:(\d+)(?:[.-][\w.-]*)?$/);
2569
+ if (postgres) image = `${RAILWAY_POSTGRES_IMAGE}:${postgres[1]}`;
2570
+ source = {
2571
+ kind: "image",
2572
+ image
2573
+ };
2574
+ } else throw new Error(`${service} has neither an image nor a build.`);
2575
+ let startCommand = definition.entrypoint?.length || definition.command?.length ? [...definition.entrypoint ?? [], ...definition.command ?? []] : void 0;
2576
+ const bound = files.get(service);
2577
+ if (bound) {
2578
+ if (definition.build) throw new Error(`${service} bind-mounts a file; Railway has no bind mounts. Copy the file into the image in its Dockerfile.`);
2579
+ const command = startCommand ?? IMAGE_COMMANDS.find((item) => item.pattern.test(definition.image ?? ""))?.command;
2580
+ if (!command) throw new Error(`${service} bind-mounts ${bound[0]?.target}; Railway has no bind mounts. Give the service a command in compose.yaml, or build an image with the file.`);
2581
+ startCommand = [
2582
+ "/bin/sh",
2583
+ "-c",
2584
+ [...bound.map((file) => {
2585
+ const variable = fileVariable(file.target);
2586
+ env[variable] = railwayHosts(file.content, hosts);
2587
+ return `printf %s "$${variable}" > ${commandWord(file.target)}`;
2588
+ }), `exec ${command.map(commandWord).join(" ")}`].join(" && ")
2589
+ ];
2590
+ }
2591
+ let volume;
2592
+ for (const mount of definition.volumes ?? []) {
2593
+ if (mount.type === "tmpfs" || mount.type === "bind") continue;
2594
+ if (mount.type !== "volume" || !mount.source) throw new Error(`${service} mounts a ${mount.type} volume, which Railway does not have.`);
2595
+ if (mount.source === "media" && !s3) throw new Error("Uploads must be in S3 on Railway: a Railway volume belongs to one service, and the backend, the worker and Caddy all need the uploads. Set S3_BUCKET and the other S3_ variables in .env.production (see the docs on media).");
2596
+ if (RAILWAY_DROPPED_VOLUMES.has(mount.source)) continue;
2597
+ if (volume) throw new Error(`${service} mounts two volumes, ${volume} and ${mount.target}; Railway gives a service one volume.`);
2598
+ volume = mount.target;
2599
+ if (mount.target === "/var/lib/postgresql/data" && !env.PGDATA) env.PGDATA = `${mount.target}/pgdata`;
2600
+ }
2601
+ const replicas = definition.deploy?.replicas ?? 1;
2602
+ services.push({
2603
+ service,
2604
+ name,
2605
+ source,
2606
+ env,
2607
+ ...startCommand ? { startCommand: startCommand.map(commandWord).join(" ") } : {},
2608
+ ...port !== void 0 ? { port } : {},
2609
+ ...health && health.port === port ? { healthcheckPath: health.path } : {},
2610
+ replicas: volume ? 1 : Math.max(1, replicas),
2611
+ ...volume ? { volume } : {},
2612
+ ...watchPatterns ? { watchPatterns } : {},
2613
+ public: service === publicService
2614
+ });
2615
+ }
2616
+ return {
2617
+ name: project.name,
2618
+ services,
2619
+ ignored: [...ignored].sort()
2620
+ };
2621
+ }
2622
+ // src/deploy/railway.ts
2623
+ /**
2624
+ * Railway (https://railway.com), through its GraphQL API with an account or
2625
+ * workspace token. Railway has no Compose: the CLI reads the resolved
2626
+ * compose.yaml and creates one Railway service per production service in a
2627
+ * Railway project. Railway builds the backend and the frontend from their
2628
+ * directories in the GitHub repository (through the Railway GitHub App) and
2629
+ * deploys again on every push; Postgres runs Railway's own Postgres image
2630
+ * with a volume, and Caddy gets the Caddyfile through a variable. Railway's
2631
+ * edge holds the certificate of the site's domain and forwards to Caddy on
2632
+ * port 80, which routes to the other services on the private network.
2633
+ *
2634
+ * Link ids: project = the Railway project, environment = its environment,
2635
+ * application = the service with the domain (the proxy).
2636
+ */
2637
+ const RAILWAY_API = "https://backboard.railway.com/graphql/v2";
2638
+ /** Variables that put Caddy behind Railway's edge. */
2639
+ const RAILWAY_PLATFORM_ENV = {
2640
+ CADDY_ADDRESS: ":80",
2641
+ TRUSTED_PROXIES: "0.0.0.0/0 ::/0",
2642
+ NUM_PROXIES: "2"
2643
+ };
2644
+ var RailwayError = class extends Error {
2645
+ status;
2646
+ constructor(message, status) {
2647
+ super(message);
2648
+ this.status = status;
2649
+ }
2650
+ };
2651
+ var RailwayApi = class {
2652
+ token;
2653
+ fetchImpl;
2654
+ url;
2655
+ constructor(token, fetchImpl = fetch, url = RAILWAY_API) {
2656
+ this.token = token;
2657
+ this.fetchImpl = fetchImpl;
2658
+ this.url = url;
2659
+ }
2660
+ async query(query, variables = {}) {
2661
+ let response;
2662
+ try {
2663
+ response = await this.fetchImpl(this.url, {
2664
+ method: "POST",
2665
+ headers: {
2666
+ Authorization: `Bearer ${this.token}`,
2667
+ "Content-Type": "application/json",
2668
+ Accept: "application/json"
2669
+ },
2670
+ body: JSON.stringify({
2671
+ query,
2672
+ variables
2673
+ }),
2674
+ signal: AbortSignal.timeout(3e4)
2675
+ });
2676
+ } catch (error) {
2677
+ throw new RailwayError(`Could not reach Railway: ${error.message}`, 0);
2678
+ }
2679
+ const raw = await response.text();
2680
+ let body;
2681
+ try {
2682
+ body = raw ? JSON.parse(raw) : void 0;
2683
+ } catch {}
2684
+ const message = body?.errors?.map((item) => item.message).filter(Boolean).join("; ");
2685
+ if (response.status === 401 || response.status === 403 || /not authori[sz]ed/i.test(message ?? "")) throw new RailwayError("Railway refused the token. Create an account or workspace token at https://railway.com/account/tokens and run dstack deploy login.", response.status);
2686
+ if (!response.ok || message || !body?.data) throw new RailwayError(`Railway answered ${response.status}: ${message || raw.slice(0, 200) || "no details"}`, response.status);
2687
+ return body.data;
2688
+ }
2689
+ };
2690
+ /** A deployment's status in Railway's words. */
2691
+ function railwayDeploymentStatus(value) {
2692
+ switch (value) {
2693
+ case "SUCCESS":
2694
+ case "SLEEPING":
2695
+ case "REMOVED":
2696
+ case "REMOVING": return "finished";
2697
+ case "BUILDING":
2698
+ case "DEPLOYING": return "building";
2699
+ case "QUEUED":
2700
+ case "WAITING":
2701
+ case "INITIALIZING":
2702
+ case "NEEDS_APPROVAL": return "queued";
2703
+ case "SKIPPED": return "cancelled";
2704
+ default: return "failed";
2705
+ }
2706
+ }
2707
+ /** The state of one deploy across services: failed when one failed, done when all are. */
2708
+ function groupStatus(statuses) {
2709
+ if (statuses.includes("failed")) return "failed";
2710
+ if (statuses.includes("building")) return "building";
2711
+ if (statuses.includes("queued")) return "queued";
2712
+ if (statuses.length && statuses.every((status) => status === "cancelled")) return "cancelled";
2713
+ return "finished";
2714
+ }
2715
+ const DEPLOYMENT_FIELDS = "id status createdAt updatedAt meta";
2716
+ const PROJECT_QUERY = `query Project($id: String!) {
2717
+ project(id: $id) {
2718
+ id name
2719
+ services { edges { node { id name } } }
2720
+ environments { edges { node { id name serviceInstances { edges { node {
2721
+ serviceId serviceName
2722
+ source { repo image }
2723
+ latestDeployment { ${DEPLOYMENT_FIELDS} }
2724
+ domains { customDomains { domain } serviceDomains { domain } }
2725
+ } } } } } }
2726
+ }
2727
+ }`;
2728
+ /** owner/name of a GitHub repository URL; Railway builds from GitHub only. */
2729
+ function githubRepository(url) {
2730
+ const match = url.match(/^https:\/\/github\.com\/([\w.-]+\/[\w.-]+?)(?:\.git)?\/?$/);
2731
+ if (!match?.[1]) throw new Error(`Railway builds from GitHub repositories only, and ${url} is not one. Push the project to GitHub first.`);
2732
+ return match[1];
2733
+ }
2734
+ function commitOf(deployment) {
2735
+ const value = deployment.meta?.commitHash;
2736
+ return typeof value === "string" && value ? value : void 0;
2737
+ }
2738
+ function metaText(deployment, key) {
2739
+ const value = deployment.meta?.[key];
2740
+ return typeof value === "string" && value ? value : void 0;
2741
+ }
2742
+ function railwayDashboardUrl(link) {
2743
+ return `https://railway.com/project/${encodeURIComponent(link.project)}?environmentId=${encodeURIComponent(link.environment)}`;
2744
+ }
2745
+ function find(items, wanted) {
2746
+ return items.find((item) => item.id === wanted) ?? items.find((item) => item.name === wanted);
2747
+ }
2748
+ var RailwayClient = class {
2749
+ api;
2750
+ run;
2751
+ readFile;
2752
+ constructor(token, fetchImpl, options = {}) {
2753
+ this.api = new RailwayApi(token, fetchImpl);
2754
+ this.run = options.run ?? systemRunner;
2755
+ this.readFile = options.readFile ?? ((path) => readFileSync(path, "utf8"));
2756
+ }
2757
+ /** How long `deploy` and `rollback` wait between looks for a new deployment; tests set it to 0. */
2758
+ pollMs = 1e3;
2759
+ async verify() {
2760
+ const data = await this.api.query("query { me { name email } }");
2761
+ const who = data.me?.email ?? data.me?.name;
2762
+ return who ? `API (${who})` : "API";
2763
+ }
2764
+ async plan(directory, env) {
2765
+ return planRailway(await resolveCompose(this.run, directory, env), {
2766
+ directory,
2767
+ readFile: this.readFile
2768
+ });
2769
+ }
2770
+ async project(id) {
2771
+ return (await this.api.query(PROJECT_QUERY, { id })).project;
2772
+ }
2773
+ /** The service instances of the link's environment. */
2774
+ async instances(link) {
2775
+ const environment = (await this.project(link.project)).environments.edges.find((edge) => edge.node.id === link.environment);
2776
+ if (!environment) throw new Error(`The Railway project has no environment ${link.environment}.`);
2777
+ return environment.node.serviceInstances.edges.map((edge) => edge.node);
2778
+ }
2779
+ /** The services Railway builds from the repository; the first one is the one deploys are listed by. */
2780
+ async built(link) {
2781
+ const items = (await this.instances(link)).filter((item) => item.source?.repo);
2782
+ if (!items.length) throw new Error("No service of the Railway project builds from the repository.");
2783
+ return items.sort((a, b) => a.serviceName === "backend" ? -1 : b.serviceName === "backend" ? 1 : a.serviceName.localeCompare(b.serviceName));
2784
+ }
2785
+ async workspace(wanted, context) {
2786
+ const workspaces = (await this.api.query("query { me { workspaces { id name } } }")).me.workspaces ?? [];
2787
+ if (wanted) {
2788
+ const workspace = find(workspaces, wanted);
2789
+ if (!workspace) throw new Error(`Railway has no workspace ${wanted}.`);
2790
+ return workspace.id;
2791
+ }
2792
+ if (workspaces.length === 1) return workspaces[0]?.id;
2793
+ if (!workspaces.length) throw new Error("This Railway token sees no workspace; pass --workspace.");
2794
+ return context.ask.select("Railway workspace", workspaces.map((item) => ({
2795
+ value: item.id,
2796
+ label: item.name
2797
+ })), workspaces[0]?.id);
2798
+ }
2799
+ async projects(workspaceId) {
2800
+ return (((await this.api.query("query { me { workspaces { id projects(first: 500) { edges { node { id name } } } } } }")).me.workspaces ?? []).find((item) => item.id === workspaceId)?.projects?.edges ?? []).map((edge) => edge.node);
2801
+ }
2802
+ async setup(context) {
2803
+ const { ask, options, directory } = context;
2804
+ if (!context.repository) throw new Error("Railway builds from a GitHub repository; pass --repository.");
2805
+ const repo = githubRepository(context.repository);
2806
+ const plan = await this.plan(directory, context.env);
2807
+ if (plan.ignored.length) ask.step(`Railway does not apply these settings of compose.yaml: ${plan.ignored.join(", ")}`);
2808
+ const workspaceId = await this.workspace(options.workspace, context);
2809
+ let projectId;
2810
+ if (options.project) {
2811
+ const project = find(await this.projects(workspaceId), options.project);
2812
+ if (!project) throw new Error(`The Railway workspace has no project ${options.project}.`);
2813
+ projectId = project.id;
2814
+ } else {
2815
+ projectId = (await this.api.query(`mutation ($input: ProjectCreateInput!) { projectCreate(input: $input) { id } }`, { input: {
2816
+ name: context.name,
2817
+ description: "DjangoStack",
2818
+ workspaceId
2819
+ } })).projectCreate.id;
2820
+ ask.step(`Created the Railway project ${context.name}`);
2821
+ }
2822
+ const environmentName = options.environment ?? "production";
2823
+ let project = await this.project(projectId);
2824
+ let environmentId = project.environments.edges.find((edge) => edge.node.id === environmentName || edge.node.name === environmentName)?.node.id;
2825
+ if (!environmentId) {
2826
+ environmentId = (await this.api.query(`mutation ($input: EnvironmentCreateInput!) { environmentCreate(input: $input) { id } }`, { input: {
2827
+ projectId,
2828
+ name: environmentName
2829
+ } })).environmentCreate.id;
2830
+ ask.step(`Created the environment ${environmentName}`);
2831
+ project = await this.project(projectId);
2832
+ }
2833
+ const existing = project.services.edges.map((edge) => edge.node);
2834
+ for (const name of plan.services.map((item) => item.name)) if (existing.some((item) => item.name === name)) throw new Error(`The Railway project already has a service ${name}. Set up a new project, or delete the service in Railway first.`);
2835
+ let publicId;
2836
+ for (const service of plan.services) {
2837
+ const serviceId = (await this.api.query(`mutation ($input: ServiceCreateInput!) { serviceCreate(input: $input) { id } }`, { input: {
2838
+ projectId,
2839
+ environmentId,
2840
+ name: service.name
2841
+ } })).serviceCreate.id;
2842
+ if (service.public) publicId = serviceId;
2843
+ const env = { ...service.env };
2844
+ if (service.source.kind === "repo" && service.source.dockerfile) env.RAILWAY_DOCKERFILE_PATH = service.source.dockerfile;
2845
+ await this.upsertVariables(projectId, environmentId, serviceId, env, true);
2846
+ await this.api.query(`mutation ($serviceId: String!, $environmentId: String, $input: ServiceInstanceUpdateInput!) {
2847
+ serviceInstanceUpdate(serviceId: $serviceId, environmentId: $environmentId, input: $input)
2848
+ }`, {
2849
+ serviceId,
2850
+ environmentId,
2851
+ input: {
2852
+ ...service.source.kind === "repo" ? { rootDirectory: service.source.rootDirectory } : {},
2853
+ ...service.startCommand ? { startCommand: service.startCommand } : {},
2854
+ ...service.healthcheckPath ? { healthcheckPath: service.healthcheckPath } : {},
2855
+ ...service.watchPatterns ? { watchPatterns: service.watchPatterns } : {},
2856
+ numReplicas: service.replicas
2857
+ }
2858
+ });
2859
+ if (service.volume) await this.api.query(`mutation ($input: VolumeCreateInput!) { volumeCreate(input: $input) { id } }`, { input: {
2860
+ projectId,
2861
+ environmentId,
2862
+ serviceId,
2863
+ mountPath: service.volume
2864
+ } });
2865
+ await this.api.query(`mutation ($id: String!, $input: ServiceConnectInput!) { serviceConnect(id: $id, input: $input) { id } }`, {
2866
+ id: serviceId,
2867
+ input: service.source.kind === "repo" ? {
2868
+ repo,
2869
+ branch: context.branch
2870
+ } : { image: service.source.image }
2871
+ });
2872
+ ask.step(`Created the service ${service.name} ${service.source.kind === "repo" ? `from ${repo}${service.source.rootDirectory} (${context.branch})` : `from ${service.source.image}`}${service.volume ? ` with a volume at ${service.volume}` : ""}`);
2873
+ }
2874
+ if (!publicId) throw new Error("The plan has no public service.");
2875
+ const domain = await this.api.query(`mutation ($input: CustomDomainCreateInput!) {
2876
+ customDomainCreate(input: $input) { id status { dnsRecords { hostlabel fqdn recordType requiredValue } } }
2877
+ }`, { input: {
2878
+ projectId,
2879
+ environmentId,
2880
+ serviceId: publicId,
2881
+ domain: context.domain,
2882
+ targetPort: plan.services.find((item) => item.public)?.port ?? 80
2883
+ } });
2884
+ ask.step(`Gave the ${plan.services.find((item) => item.public)?.name} service https://${context.domain}`);
2885
+ const records = domain.customDomainCreate.status?.dnsRecords ?? [];
2886
+ if (records.length) for (const record of records) ask.step(`Add this DNS record: ${record.fqdn ?? record.hostlabel ?? context.domain} ${record.recordType?.replace(/^DNS_RECORD_TYPE_/, "") ?? "CNAME"} ${record.requiredValue ?? ""}`.trim());
2887
+ else ask.step(`Add the DNS record Railway shows for ${context.domain} under the proxy service's settings.`);
2888
+ return {
2889
+ project: projectId,
2890
+ environment: environmentId,
2891
+ application: publicId
2892
+ };
2893
+ }
2894
+ async upsertVariables(projectId, environmentId, serviceId, variables, skipDeploys) {
2895
+ await this.api.query(`mutation ($input: VariableCollectionUpsertInput!) { variableCollectionUpsert(input: $input) }`, { input: {
2896
+ projectId,
2897
+ environmentId,
2898
+ serviceId,
2899
+ variables,
2900
+ skipDeploys
2901
+ } });
2902
+ }
2903
+ async pushEnv(link, env) {
2904
+ const directory = link.directory ?? process.cwd();
2905
+ const plan = await this.plan(directory, env);
2906
+ const instances = await this.instances(link);
2907
+ for (const service of plan.services) {
2908
+ const instance = instances.find((item) => item.serviceName === service.name);
2909
+ if (!instance) throw new Error(missingService(service.name));
2910
+ await this.upsertVariables(link.project, link.environment, instance.serviceId, service.env, false);
2911
+ }
2912
+ }
2913
+ async deploy(link) {
2914
+ const services = await this.built(link);
2915
+ let primary;
2916
+ for (const service of services) {
2917
+ const data = await this.api.query(`mutation ($serviceId: String!, $environmentId: String!) {
2918
+ serviceInstanceDeployV2(serviceId: $serviceId, environmentId: $environmentId)
2919
+ }`, {
2920
+ serviceId: service.serviceId,
2921
+ environmentId: link.environment
2922
+ });
2923
+ primary ??= typeof data.serviceInstanceDeployV2 === "string" ? data.serviceInstanceDeployV2 : void 0;
2924
+ }
2925
+ if (primary) return primary;
2926
+ return this.newest(link, services[0]?.serviceId, services[0]?.latestDeployment?.id);
2927
+ }
2928
+ /** Waits for a deployment newer than `before` of a service; returns its id. */
2929
+ async newest(link, serviceId, before) {
2930
+ for (let attempt = 0; attempt < 20; attempt++) {
2931
+ const [latest] = await this.raw(link, serviceId, 1);
2932
+ if (latest && latest.id !== before) return latest.id;
2933
+ await new Promise((resolve) => setTimeout(resolve, this.pollMs));
2934
+ }
2935
+ throw new Error(`Railway took the deploy but shows no deployment for it yet. See ${railwayDashboardUrl(link)}`);
2936
+ }
2937
+ /** A service's deployments, newest first. */
2938
+ async raw(link, serviceId, limit) {
2939
+ return (await this.api.query(`query ($input: DeploymentListInput!, $first: Int) {
2940
+ deployments(input: $input, first: $first) { edges { node { ${DEPLOYMENT_FIELDS} } } }
2941
+ }`, {
2942
+ input: {
2943
+ projectId: link.project,
2944
+ environmentId: link.environment,
2945
+ serviceId
2946
+ },
2947
+ first: limit
2948
+ })).deployments.edges.map((edge) => edge.node).sort((a, b) => String(b.createdAt ?? "").localeCompare(String(a.createdAt ?? "")));
2949
+ }
2950
+ /** The deployments of the other built services that belong to a deployment of the first one. */
2951
+ siblings(primary, others) {
2952
+ const commit = commitOf(primary);
2953
+ const time = new Date(primary.createdAt ?? 0).getTime();
2954
+ const result = [];
2955
+ for (const { service, deployments } of others) {
2956
+ const match = commit ? deployments.find((item) => commitOf(item) === commit) : deployments.filter((item) => Math.abs(new Date(item.createdAt ?? 0).getTime() - time) < 6e5).sort((a, b) => Math.abs(new Date(a.createdAt ?? 0).getTime() - time) - Math.abs(new Date(b.createdAt ?? 0).getTime() - time))[0];
2957
+ if (match) result.push({
2958
+ service,
2959
+ deployment: match
2960
+ });
2961
+ }
2962
+ return result;
2963
+ }
2964
+ async group(link, limit) {
2965
+ const [first, ...rest] = await this.built(link);
2966
+ if (!first) throw new Error("No service of the Railway project builds from the repository.");
2967
+ return {
2968
+ first,
2969
+ primary: await this.raw(link, first.serviceId, limit),
2970
+ others: await Promise.all(rest.map(async (service) => ({
2971
+ service,
2972
+ deployments: await this.raw(link, service.serviceId, Math.max(limit, 10) * 2)
2973
+ })))
2974
+ };
2975
+ }
2976
+ toDeployment(link, primary, siblings) {
2977
+ const statuses = [primary, ...siblings.map((item) => item.deployment)].map((item) => railwayDeploymentStatus(item.status));
2978
+ const commit = commitOf(primary);
2979
+ return {
2980
+ id: primary.id,
2981
+ status: groupStatus(statuses),
2982
+ commit: commit?.slice(0, 7),
2983
+ message: metaText(primary, "commitMessage")?.split("\n")[0],
2984
+ createdAt: primary.createdAt,
2985
+ updatedAt: primary.updatedAt,
2986
+ url: railwayDashboardUrl(link),
2987
+ log: []
2988
+ };
2989
+ }
2990
+ async deployment(link, id) {
2991
+ const { first, primary, others } = await this.group(link, 20);
2992
+ let item = primary.find((deployment) => deployment.id === id);
2993
+ if (!item) item = (await this.api.query(`query ($id: String!) { deployment(id: $id) { ${DEPLOYMENT_FIELDS} } }`, { id }).catch(() => void 0))?.deployment;
2994
+ if (!item) throw new Error(`Railway has no deployment ${id} for this project.`);
2995
+ const siblings = this.siblings(item, others);
2996
+ const deployment = this.toDeployment(link, item, siblings);
2997
+ const log = [];
2998
+ for (const [service, part] of [[first, item], ...siblings.map((sibling) => [sibling.service, sibling.deployment])]) {
2999
+ const lines = await this.logs(part).catch(() => []);
3000
+ for (const line of lines) log.push({
3001
+ ...line,
3002
+ text: `${service.serviceName} | ${line.text}`
3003
+ });
3004
+ }
3005
+ return {
3006
+ ...deployment,
3007
+ log
3008
+ };
3009
+ }
3010
+ /** The build log of a deployment, and its deploy log once it runs. */
3011
+ async logs(deployment) {
3012
+ const toLines = (items) => items.map((item) => ({
3013
+ text: item.message ?? "",
3014
+ stderr: /error/i.test(item.severity ?? "")
3015
+ }));
3016
+ const lines = toLines((await this.api.query(`query ($deploymentId: String!, $limit: Int) { buildLogs(deploymentId: $deploymentId, limit: $limit) { message severity } }`, {
3017
+ deploymentId: deployment.id,
3018
+ limit: 500
3019
+ })).buildLogs ?? []);
3020
+ if ([
3021
+ "DEPLOYING",
3022
+ "SUCCESS",
3023
+ "CRASHED",
3024
+ "FAILED"
3025
+ ].includes(deployment.status ?? "")) {
3026
+ const run = await this.api.query(`query ($deploymentId: String!, $limit: Int) { deploymentLogs(deploymentId: $deploymentId, limit: $limit) { message severity } }`, {
3027
+ deploymentId: deployment.id,
3028
+ limit: 200
3029
+ }).catch(() => ({ deploymentLogs: [] }));
3030
+ lines.push(...toLines(run.deploymentLogs ?? []));
3031
+ }
3032
+ return lines;
3033
+ }
3034
+ async deployments(link, limit) {
3035
+ const { primary, others } = await this.group(link, Math.max(1, limit));
3036
+ return primary.slice(0, limit).map((item) => this.toDeployment(link, item, this.siblings(item, others)));
3037
+ }
3038
+ async rollback(link, to) {
3039
+ const { first, primary, others } = await this.group(link, 20);
3040
+ let target;
3041
+ if (to) {
3042
+ target = primary.find((item) => item.id === to);
3043
+ if (!target) throw new Error(`Railway has no deployment ${to} among the last 20.`);
3044
+ } else {
3045
+ const running = primary.findIndex((item) => item.status === "SUCCESS");
3046
+ target = primary.slice(running + 1).find((item) => ["SUCCESS", "REMOVED"].includes(item.status ?? "") && running >= 0);
3047
+ if (!target) throw new Error("There is no earlier deploy to roll back to.");
3048
+ }
3049
+ if (railwayDeploymentStatus(target.status) !== "finished") throw new Error(`Deploy ${target.id} did not finish, so it cannot be rolled back to.`);
3050
+ const before = primary[0]?.id;
3051
+ for (const deployment of [target, ...this.siblings(target, others).map((item) => item.deployment)]) await this.api.query(`mutation ($id: String!) { deploymentRollback(id: $id) }`, { id: deployment.id });
3052
+ return this.newest(link, first.serviceId, before);
3053
+ }
3054
+ async status(link) {
3055
+ const instances = await this.instances(link);
3056
+ const states = instances.map((item) => item.latestDeployment?.status);
3057
+ let state = "unknown";
3058
+ if (!states.some(Boolean)) state = "stopped";
3059
+ else if (states.every((value) => value === "SUCCESS" || value === "SLEEPING")) state = "running";
3060
+ else if (states.some((value) => value === "FAILED" || value === "CRASHED" || value === "REMOVED")) state = "degraded";
3061
+ else if (states.some((value) => railwayDeploymentStatus(value ?? "") !== "finished")) state = "degraded";
3062
+ const detail = instances.map((item) => `${item.serviceName}:${(item.latestDeployment?.status ?? "none").toLowerCase()}`).join(", ");
3063
+ const domains = (instances.find((item) => item.serviceId === link.application)?.domains?.customDomains ?? []).map((item) => `https://${item.domain}`);
3064
+ const branch = (instances.find((item) => item.source?.repo && item.serviceName === "backend") ?? instances.find((item) => item.source?.repo))?.latestDeployment;
3065
+ return {
3066
+ state,
3067
+ detail: detail || "no services",
3068
+ domains,
3069
+ branch: branch ? metaText(branch, "branch") : void 0,
3070
+ url: railwayDashboardUrl(link)
3071
+ };
3072
+ }
3073
+ };
3074
+ function missingService(name) {
3075
+ return `The Railway project has no service ${name}. compose.yaml changed since setup: add the service in Railway, or run dstack deploy setup --provider railway --force to create a new project.`;
3076
+ }
3077
+ const railway = {
3078
+ id: "railway",
3079
+ name: "Railway",
3080
+ auth: {
3081
+ kind: "token",
3082
+ env: "DSTACK_RAILWAY_TOKEN",
3083
+ help: () => "Create an account token, or a token of the workspace to deploy to, at https://railway.com/account/tokens. Connect the Railway GitHub App to the repository too: Railway builds from GitHub."
3084
+ },
3085
+ source: "remote",
3086
+ target: {
3087
+ label: "Railway API",
3088
+ placeholder: RAILWAY_API,
3089
+ fixed: RAILWAY_API,
3090
+ normalize: () => RAILWAY_API
3091
+ },
3092
+ platformEnv: RAILWAY_PLATFORM_ENV,
3093
+ setupFlags: [
3094
+ {
3095
+ name: "workspace",
3096
+ description: "Railway: the workspace, by name or id (default: the only one)"
3097
+ },
3098
+ {
3099
+ name: "project",
3100
+ description: "The platform's project, by name or id (default: the project name)"
3101
+ },
3102
+ {
3103
+ name: "environment",
3104
+ description: "The platform's environment (default: production)"
3105
+ }
3106
+ ],
3107
+ printsDns: true,
3108
+ connect: (_target, token) => {
3109
+ if (!token) throw new Error("Railway needs an API key.");
3110
+ return new RailwayClient(token);
3111
+ }
3112
+ };
2383
3113
  // src/deploy/credentials.ts
2384
3114
  /**
2385
3115
  * API keys of deploy providers, never in the project: the OS keychain
@@ -2489,7 +3219,8 @@ const PROVIDERS = [
2489
3219
  coolify,
2490
3220
  dokploy,
2491
3221
  ssh,
2492
- bunny
3222
+ bunny,
3223
+ railway
2493
3224
  ];
2494
3225
  function getProvider(id) {
2495
3226
  const provider = PROVIDERS.find((item) => item.id === id);
@@ -3338,6 +4069,10 @@ async function init(directoryArgument, options) {
3338
4069
  localS3: stack.storage === "s3",
3339
4070
  analytics: stack.analytics === "umami",
3340
4071
  cache: stack.cache,
4072
+ notifications: stack.notifications === "on",
4073
+ captcha: stack.captcha === "on",
4074
+ packageManager: stack.packageManager,
4075
+ errors: stack.errors,
3341
4076
  tasks: stack.tasks,
3342
4077
  preset: encodePreset(stack),
3343
4078
  timeZone,
@@ -3354,6 +4089,16 @@ async function init(directoryArgument, options) {
3354
4089
  if (lockBackend(target)) spinner.stop(`Locked the backend dependencies in ${pc.bold("backend/uv.lock")}`);
3355
4090
  else spinner.error("Could not lock the backend dependencies. The image build resolves them instead.");
3356
4091
  }
4092
+ if (NODE_FRONTENDS.includes(stack.frontend)) {
4093
+ const lockfile = {
4094
+ pnpm: "pnpm-lock.yaml",
4095
+ npm: "package-lock.json",
4096
+ bun: "bun.lock"
4097
+ }[stack.packageManager];
4098
+ spinner.start(`Locking the frontend dependencies with ${stack.packageManager}`);
4099
+ if (lockFrontend(target, stack.packageManager)) spinner.stop(`Locked the frontend dependencies in ${pc.bold(`frontend/${lockfile}`)}`);
4100
+ else spinner.error("Could not lock the frontend dependencies. The image build resolves them instead.");
4101
+ }
3357
4102
  if (options.git && has("git")) try {
3358
4103
  execFileSync("git", ["init", "--quiet"], {
3359
4104
  cwd: target,
@@ -3778,7 +4523,9 @@ const SWITCHABLE = [
3778
4523
  "analytics",
3779
4524
  "media",
3780
4525
  "cache",
3781
- "tasks"
4526
+ "tasks",
4527
+ "notifications",
4528
+ "captcha"
3782
4529
  ];
3783
4530
  const EMPTY_S3 = Object.fromEntries(Object.keys(LOCAL_S3).map((key) => [key, key === "S3_CREATE_BUCKET" ? "0" : ""]));
3784
4531
  /**
@@ -3845,6 +4592,11 @@ function applyStack(envText, env, from, to) {
3845
4592
  changes.push("Page analytics are off; Umami's data stays in the database.");
3846
4593
  }
3847
4594
  }
4595
+ if (key === "notifications") changes.push(to.notifications === "on" ? "Notifications are on: content_studio.notifications is in INSTALLED_APPS." : "Notifications are off: content_studio.notifications is out of INSTALLED_APPS; its tables stay.");
4596
+ if (key === "captcha") {
4597
+ text = setEnvValues(text, { CAPTCHA: to.captcha });
4598
+ changes.push(to.captcha === "on" ? "The captcha of the contact form is on." : "The captcha is off: public forms need no captcha token.");
4599
+ }
3848
4600
  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.");
3849
4601
  }
3850
4602
  return {
@@ -3984,6 +4736,20 @@ async function manage(args, options) {
3984
4736
  ...args
3985
4737
  ])));
3986
4738
  }
4739
+ /** `dstack status`: the containers, then the database, cache, worker and backup checks. */
4740
+ async function status(options) {
4741
+ const project = activeProject(open(options));
4742
+ if (!runningServices(project).includes(project.services.backend)) fail(`The ${project.services.backend} service is not running.`);
4743
+ compose(project, ["ps"]);
4744
+ exitWith(compose(project, [
4745
+ "exec",
4746
+ "-T",
4747
+ project.services.backend,
4748
+ "python",
4749
+ "manage.py",
4750
+ "health"
4751
+ ]));
4752
+ }
3987
4753
  /** `dstack shell`: Django's shell in the backend container. */
3988
4754
  async function shell(options) {
3989
4755
  return manage(["shell"], options);
@@ -4249,6 +5015,11 @@ async function update(options) {
4249
5015
  }).status !== 0) fail("uv could not update backend/uv.lock.");
4250
5016
  p.log.success(options.all ? "Updated backend/uv.lock" : "Updated django-stack in backend/uv.lock");
4251
5017
  } else if (existsSync(lock)) p.log.warn("uv is not installed, so backend/uv.lock stays as it is: https://docs.astral.sh/uv/");
5018
+ if (frontendPackageManager(project.directory)) {
5019
+ const lockfile = updateFrontendLock(project.directory, options.all);
5020
+ if (!lockfile) fail("The package manager could not update the frontend's lockfile.");
5021
+ p.log.success(options.all ? `Updated frontend/${lockfile}` : `Updated the @django-stack packages in frontend/${lockfile}`);
5022
+ }
4252
5023
  for (const target of targets) {
4253
5024
  if (compose(target, ["pull", "--ignore-buildable"]) !== 0) fail("Pulling the images failed.");
4254
5025
  const build = [
@@ -4308,6 +5079,19 @@ async function config(options) {
4308
5079
  const compose = join(project.directory, "compose.yaml");
4309
5080
  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.");
4310
5081
  }
5082
+ const settingsPath = join(project.directory, "backend", "config", "settings.py");
5083
+ if (next.captcha === "on" && current.captcha !== "on") {
5084
+ if (!existsSync(settingsPath) || !CAPTCHA_APP.test(readFileSync(settingsPath, "utf8"))) fail("The backend has no django_stack.contrib.captcha. Add it and the widget as the captcha docs describe, then run this again.");
5085
+ }
5086
+ let settingsText;
5087
+ if (next.notifications !== current.notifications) {
5088
+ if (!existsSync(settingsPath)) fail("This project has no backend/config/settings.py.");
5089
+ try {
5090
+ settingsText = setNotificationsApp(readFileSync(settingsPath, "utf8"), next.notifications === "on");
5091
+ } catch (error) {
5092
+ fail(error.message);
5093
+ }
5094
+ }
4311
5095
  const envPath = join(project.directory, ".env");
4312
5096
  let result;
4313
5097
  try {
@@ -4321,10 +5105,11 @@ async function config(options) {
4321
5105
  }
4322
5106
  const envText = readFileSync(envPath, "utf8");
4323
5107
  if (result.env !== envText) writeFileSync(envPath, result.env, { mode: 384 });
5108
+ if (settingsText !== void 0) writeFileSync(settingsPath, settingsText);
4324
5109
  const manifestPath = join(project.directory, "package.json");
4325
5110
  if (existsSync(manifestPath)) writeFileSync(manifestPath, setManifestPreset(readFileSync(manifestPath, "utf8"), encodePreset(next)));
4326
5111
  for (const change of result.changes) p.log.success(change);
4327
- if (result.env === envText) p.outro(pc.green("Done"));
5112
+ if (result.env === envText && settingsText === void 0) p.outro(pc.green("Done"));
4328
5113
  else p.outro(`Restart to apply it: ${command("dstack stop")}, then ${command("dstack dev")}`);
4329
5114
  }
4330
5115
  // src/update-check.ts
@@ -4539,8 +5324,9 @@ function createProgram() {
4539
5324
  program.command("dev").description("Run the development servers with hot reload").addOption(dirOption()).option("--no-build", "Don't build the images first").action(dev);
4540
5325
  program.command("stop").description("Stop the project; the data stays").addOption(dirOption()).action(stop);
4541
5326
  program.command("logs").description("Show the logs of the services").argument("[services...]", "Only these services, such as backend").addOption(dirOption()).option("--no-follow", "Print the logs and exit").option("-n, --tail <lines>", "Only the last lines of each service").action(logs);
5327
+ program.command("status").description("Show the containers and whether the database, cache, worker and backups are healthy").addOption(dirOption()).action(status);
4542
5328
  program.command("check").description("Check the project: setup, Django checks, migrations and linters").addOption(dirOption()).option("--tests", "Run the backend and frontend tests too").action(check);
4543
- program.command("update").description("Pull and rebuild the images and update the DjangoStack packages").addOption(dirOption()).option("--all", "Update every backend package in uv.lock, not only django-stack").option("--no-cache", "Rebuild the images from scratch").option("--no-restart", "Don't restart what was running").action(update);
5329
+ program.command("update").description("Pull and rebuild the images and update the DjangoStack packages").addOption(dirOption()).option("--all", "Update every package in the lockfiles, not only DjangoStack's").option("--no-cache", "Rebuild the images from scratch").option("--no-restart", "Don't restart what was running").action(update);
4544
5330
  const configCommand = program.command("config").description("Change the stack options of a project").addOption(dirOption()).option("-y, --yes", "Don't ask; change only what the flags set").action((options, cmd) => config({
4545
5331
  ...options,
4546
5332
  fromCli: (option) => cmd.getOptionValueSource(option) === "cli"
@@ -4552,7 +5338,7 @@ function createProgram() {
4552
5338
  program.command("restore").description("Restore a backup over the database or the media (prod profile)").argument("<backup>", "A file name from `dstack backup --list`").addOption(dirOption()).option("--media", "Also restore the media backed up with this database dump").option("-y, --yes", "Don't ask for confirmation").action(restore);
4553
5339
  program.command("types").description("Generate the frontend's API types from the backend's schema").addOption(dirOption()).action(types);
4554
5340
  const providerOption = () => new Option("--provider <provider>", `The platform (default: ${DEFAULT_PROVIDER.id})`).choices(PROVIDERS.map((provider) => provider.id));
4555
- const deployCommand = program.command("deploy").description("Deploy to Coolify, Dokploy, Bunny or a server over SSH, and show the status");
5341
+ const deployCommand = program.command("deploy").description("Deploy to Coolify, Dokploy, Bunny, Railway or a server over SSH, and show the status");
4556
5342
  deployCommand.command("start", { isDefault: true }).description("Start a deploy and follow it; the same as `dstack deploy`").addOption(dirOption()).option("--force", "Rebuild the images without cache").option("--no-wait", "Don't follow the deploy; return once it started").option("--no-push-check", "Deploy even when it would not include your local commits or changes").option("-y, --yes", "Don't ask; fail when no API key is saved").action(deploy);
4557
5343
  const setupCommand = deployCommand.command("setup").description("Create the project on the platform and link it to this directory").addOption(dirOption()).addOption(providerOption()).option("--url <url>", "The platform's address, such as https://coolify.example.com, or the server, such as deploy@example.com").option("--repository <url>", "The git repository the platform builds (default: the origin remote)").option("--branch <branch>", "The branch to deploy (default: the current one)").option("--deploy", "Deploy when done").option("--no-deploy", "Don't offer to deploy when done").option("--force", `Set up again, replacing the link in .dstack/deploy.json`).option("-y, --yes", "Don't ask; use the flags and the defaults").action(deploySetup);
4558
5344
  const seen = /* @__PURE__ */ new Set();