@django-stack/cli 0.2.0 → 0.4.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 (140) hide show
  1. package/README.md +1 -1
  2. package/dist/bin.js +861 -86
  3. package/dist/index.d.ts +52 -40
  4. package/dist/index.js +1 -1
  5. package/dist/{stack-CTyqYWCV.js → scaffold-DbgGYEsc.js} +216 -183
  6. package/package.json +1 -1
  7. package/templates/backend/pyproject.toml +1 -1
  8. package/templates/base/AGENTS.md +7 -11
  9. package/templates/base/README.md +4 -4
  10. package/templates/base/SECURITY.md +1 -1
  11. package/templates/base/gitignore +7 -0
  12. package/templates/base/package.json +1 -1
  13. package/templates/frontend-astro/AGENTS.md +43 -0
  14. package/templates/frontend-astro/package.json +1 -1
  15. package/templates/frontend-astro/template.json +5 -0
  16. package/templates/frontend-next/.dockerignore +3 -0
  17. package/templates/frontend-next/AGENTS.md +59 -0
  18. package/templates/frontend-next/Dockerfile +32 -0
  19. package/templates/frontend-next/next.config.ts +19 -0
  20. package/templates/frontend-next/package.json +34 -0
  21. package/templates/frontend-next/public/favicon.svg +1 -0
  22. package/templates/frontend-next/src/app/[...path]/page.tsx +28 -0
  23. package/templates/frontend-next/src/app/blog/[slug]/page.tsx +60 -0
  24. package/templates/frontend-next/src/app/blog/page.tsx +20 -0
  25. package/templates/frontend-next/src/app/category/[slug]/page.tsx +34 -0
  26. package/templates/frontend-next/src/app/error.tsx +13 -0
  27. package/templates/frontend-next/src/app/globals.css +250 -0
  28. package/templates/frontend-next/src/app/layout.tsx +70 -0
  29. package/templates/frontend-next/src/app/not-found.tsx +15 -0
  30. package/templates/frontend-next/src/app/page.tsx +35 -0
  31. package/templates/frontend-next/src/app/preview/exit/route.ts +3 -0
  32. package/templates/frontend-next/src/app/preview/route.ts +4 -0
  33. package/templates/frontend-next/src/app/revalidate/route.ts +8 -0
  34. package/templates/frontend-next/src/app/robots.txt/route.ts +13 -0
  35. package/templates/frontend-next/src/app/rss.xml/route.ts +19 -0
  36. package/templates/frontend-next/src/app/sitemap.xml/route.ts +28 -0
  37. package/templates/frontend-next/src/app/tag/[slug]/page.tsx +29 -0
  38. package/templates/frontend-next/src/components/ContactForm.tsx +55 -0
  39. package/templates/frontend-next/src/components/NavLink.tsx +15 -0
  40. package/templates/frontend-next/src/components/PostCard.tsx +25 -0
  41. package/templates/frontend-next/src/components/PostList.tsx +28 -0
  42. package/templates/frontend-next/src/lib/api.ts +71 -0
  43. package/templates/frontend-next/src/lib/headless.ts +344 -0
  44. package/templates/frontend-next/src/lib/rss.ts +44 -0
  45. package/templates/frontend-next/src/proxy.ts +34 -0
  46. package/templates/frontend-next/template.json +5 -0
  47. package/templates/frontend-next/test/rss.test.ts +17 -0
  48. package/templates/frontend-next/tsconfig.json +42 -0
  49. package/templates/frontend-next/vendor/.gitkeep +0 -0
  50. package/templates/frontend-next/vitest.config.ts +7 -0
  51. package/templates/frontend-nuxt/.dockerignore +4 -0
  52. package/templates/frontend-nuxt/AGENTS.md +64 -0
  53. package/templates/frontend-nuxt/Dockerfile +32 -0
  54. package/templates/frontend-nuxt/nuxt.config.ts +69 -0
  55. package/templates/frontend-nuxt/package.json +34 -0
  56. package/templates/frontend-nuxt/public/favicon.svg +1 -0
  57. package/templates/frontend-nuxt/server/plugins/media-origins.ts +27 -0
  58. package/templates/frontend-nuxt/server/routes/_content/category/[slug].get.ts +13 -0
  59. package/templates/frontend-nuxt/server/routes/_content/home.get.ts +10 -0
  60. package/templates/frontend-nuxt/server/routes/_content/layout.get.ts +13 -0
  61. package/templates/frontend-nuxt/server/routes/_content/page.get.ts +7 -0
  62. package/templates/frontend-nuxt/server/routes/_content/post/[slug].get.ts +7 -0
  63. package/templates/frontend-nuxt/server/routes/_content/posts.get.ts +4 -0
  64. package/templates/frontend-nuxt/server/routes/_content/redirect.get.ts +7 -0
  65. package/templates/frontend-nuxt/server/routes/_content/tag/[slug].get.ts +13 -0
  66. package/templates/frontend-nuxt/server/routes/preview/exit.get.ts +4 -0
  67. package/templates/frontend-nuxt/server/routes/preview/index.get.ts +26 -0
  68. package/templates/frontend-nuxt/server/routes/revalidate.post.ts +15 -0
  69. package/templates/frontend-nuxt/server/routes/robots.txt.get.ts +4 -0
  70. package/templates/frontend-nuxt/server/routes/rss.xml.get.ts +19 -0
  71. package/templates/frontend-nuxt/server/routes/sitemap.xml.get.ts +29 -0
  72. package/templates/frontend-nuxt/server/utils/cache.ts +48 -0
  73. package/templates/frontend-nuxt/server/utils/content.ts +41 -0
  74. package/templates/frontend-nuxt/server/utils/rss.ts +44 -0
  75. package/templates/frontend-nuxt/src/app.vue +5 -0
  76. package/templates/frontend-nuxt/src/assets/main.css +126 -0
  77. package/templates/frontend-nuxt/src/components/ContactForm.vue +66 -0
  78. package/templates/frontend-nuxt/src/components/PostCard.vue +43 -0
  79. package/templates/frontend-nuxt/src/components/PostList.vue +33 -0
  80. package/templates/frontend-nuxt/src/composables/useContent.ts +27 -0
  81. package/templates/frontend-nuxt/src/composables/useLayoutContent.ts +4 -0
  82. package/templates/frontend-nuxt/src/composables/usePageSeo.ts +33 -0
  83. package/templates/frontend-nuxt/src/error.vue +23 -0
  84. package/templates/frontend-nuxt/src/layouts/default.vue +99 -0
  85. package/templates/frontend-nuxt/src/lib/headless.ts +344 -0
  86. package/templates/frontend-nuxt/src/pages/[...path].vue +16 -0
  87. package/templates/frontend-nuxt/src/pages/blog/[slug].vue +43 -0
  88. package/templates/frontend-nuxt/src/pages/blog/index.vue +12 -0
  89. package/templates/frontend-nuxt/src/pages/category/[slug].vue +14 -0
  90. package/templates/frontend-nuxt/src/pages/index.vue +18 -0
  91. package/templates/frontend-nuxt/src/pages/tag/[slug].vue +13 -0
  92. package/templates/frontend-nuxt/template.json +5 -0
  93. package/templates/frontend-nuxt/test/cache.test.ts +20 -0
  94. package/templates/frontend-nuxt/test/rss.test.ts +17 -0
  95. package/templates/frontend-nuxt/tsconfig.json +9 -0
  96. package/templates/frontend-nuxt/vendor/.gitkeep +0 -0
  97. package/templates/frontend-nuxt/vitest.config.ts +7 -0
  98. package/templates/frontend-sveltekit/.dockerignore +4 -0
  99. package/templates/frontend-sveltekit/AGENTS.md +53 -0
  100. package/templates/frontend-sveltekit/Dockerfile +32 -0
  101. package/templates/frontend-sveltekit/package.json +39 -0
  102. package/templates/frontend-sveltekit/src/app.css +126 -0
  103. package/templates/frontend-sveltekit/src/app.d.ts +12 -0
  104. package/templates/frontend-sveltekit/src/app.html +12 -0
  105. package/templates/frontend-sveltekit/src/hooks.server.ts +70 -0
  106. package/templates/frontend-sveltekit/src/lib/components/ContactForm.svelte +65 -0
  107. package/templates/frontend-sveltekit/src/lib/components/PostCard.svelte +41 -0
  108. package/templates/frontend-sveltekit/src/lib/components/PostList.svelte +39 -0
  109. package/templates/frontend-sveltekit/src/lib/headless.ts +344 -0
  110. package/templates/frontend-sveltekit/src/lib/server/api.ts +28 -0
  111. package/templates/frontend-sveltekit/src/lib/server/cache.ts +58 -0
  112. package/templates/frontend-sveltekit/src/lib/server/rss.ts +44 -0
  113. package/templates/frontend-sveltekit/src/routes/+error.svelte +14 -0
  114. package/templates/frontend-sveltekit/src/routes/+layout.server.ts +18 -0
  115. package/templates/frontend-sveltekit/src/routes/+layout.svelte +112 -0
  116. package/templates/frontend-sveltekit/src/routes/+page.server.ts +10 -0
  117. package/templates/frontend-sveltekit/src/routes/+page.svelte +19 -0
  118. package/templates/frontend-sveltekit/src/routes/[...path]/+page.server.ts +9 -0
  119. package/templates/frontend-sveltekit/src/routes/[...path]/+page.svelte +12 -0
  120. package/templates/frontend-sveltekit/src/routes/blog/+page.server.ts +7 -0
  121. package/templates/frontend-sveltekit/src/routes/blog/+page.svelte +10 -0
  122. package/templates/frontend-sveltekit/src/routes/blog/[slug]/+page.server.ts +9 -0
  123. package/templates/frontend-sveltekit/src/routes/blog/[slug]/+page.svelte +41 -0
  124. package/templates/frontend-sveltekit/src/routes/category/[slug]/+page.server.ts +11 -0
  125. package/templates/frontend-sveltekit/src/routes/category/[slug]/+page.svelte +12 -0
  126. package/templates/frontend-sveltekit/src/routes/preview/+server.ts +27 -0
  127. package/templates/frontend-sveltekit/src/routes/preview/exit/+server.ts +8 -0
  128. package/templates/frontend-sveltekit/src/routes/revalidate/+server.ts +16 -0
  129. package/templates/frontend-sveltekit/src/routes/robots.txt/+server.ts +7 -0
  130. package/templates/frontend-sveltekit/src/routes/rss.xml/+server.ts +20 -0
  131. package/templates/frontend-sveltekit/src/routes/sitemap.xml/+server.ts +29 -0
  132. package/templates/frontend-sveltekit/src/routes/tag/[slug]/+page.server.ts +11 -0
  133. package/templates/frontend-sveltekit/src/routes/tag/[slug]/+page.svelte +11 -0
  134. package/templates/frontend-sveltekit/static/favicon.svg +1 -0
  135. package/templates/frontend-sveltekit/template.json +5 -0
  136. package/templates/frontend-sveltekit/test/cache.test.ts +22 -0
  137. package/templates/frontend-sveltekit/test/rss.test.ts +17 -0
  138. package/templates/frontend-sveltekit/tsconfig.json +5 -0
  139. package/templates/frontend-sveltekit/vendor/.gitkeep +0 -0
  140. package/templates/frontend-sveltekit/vite.config.ts +47 -0
package/dist/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { D as parseEnv, E as checkTools, O as setEnvValues, S as validateDomain, T as runDoctor, _ as toProjectName, a as decodePreset, b as CLI_VERSION, c as resolvePreset, d as LOCAL_S3, f as lockBackend, g as timeZones, h as scaffold, i as applies, l as stackOption, m as relativeDirectory, n as NAMED_PRESETS, o as encodePreset, r as STACK_OPTIONS, s as pendingChoices, t as DEFAULT_STACK, v as validateProjectName, w as generateSecret, x as productionEnv, y as validateTimeZone } from "./stack-CTyqYWCV.js";
2
+ import { D as parseEnv, E as checkTools, O as setEnvValues, S as validateDomain, T as runDoctor, _ as encodePreset, a as relativeDirectory, b as stackOption, c as toProjectName, d as CLI_VERSION, f as DEFAULT_STACK, g as decodePreset, h as applies, l as validateProjectName, m as STACK_OPTIONS, n as LOCAL_S3, o as scaffold, p as NAMED_PRESETS, r as lockBackend, s as timeZones, u as validateTimeZone, v as pendingChoices, w as generateSecret, x as productionEnv, y as resolvePreset } from "./scaffold-DbgGYEsc.js";
3
3
  import { createRequire } from "node:module";
4
4
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { basename, delimiter, dirname, join, resolve } from "node:path";
@@ -9,6 +9,27 @@ import tab from "@bomb.sh/tab/commander";
9
9
  import * as p from "@clack/prompts";
10
10
  import { Command, InvalidArgumentError, Option } from "commander";
11
11
  import { homedir } from "node:os";
12
+ // src/deploy/provider.ts
13
+ /** The target of a platform with a web API and an API key. */
14
+ function urlTarget(name, placeholder) {
15
+ return {
16
+ label: `${name} URL`,
17
+ placeholder,
18
+ normalize: normalizeUrl
19
+ };
20
+ }
21
+ /** Normalizes an instance URL: https by default, no trailing slash or path. */
22
+ function normalizeUrl(value) {
23
+ const trimmed = value.trim();
24
+ const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
25
+ let parsed;
26
+ try {
27
+ parsed = new URL(withScheme);
28
+ } catch {
29
+ throw new Error(`${value} is not a URL. Enter one such as https://coolify.example.com.`);
30
+ }
31
+ return `${parsed.protocol}//${parsed.host}`;
32
+ }
12
33
  // src/deploy/coolify.ts
13
34
  /**
14
35
  * Coolify (https://coolify.io), through its API at /api/v1 with a bearer
@@ -24,6 +45,21 @@ const PLATFORM_ENV = {
24
45
  HTTP_PORT: "127.0.0.1:8080",
25
46
  HTTPS_PORT: "127.0.0.1:8443"
26
47
  };
48
+ /** Setup flags Coolify and Dokploy share; `deploy setup` shows each flag once. */
49
+ const SHARED_SETUP_FLAGS = [
50
+ {
51
+ name: "server",
52
+ description: "The server on the platform, by name or id (Dokploy: dokploy for its own)"
53
+ },
54
+ {
55
+ name: "project",
56
+ description: "The platform's project, by name or id (default: the project name)"
57
+ },
58
+ {
59
+ name: "environment",
60
+ description: "The platform's environment (default: production)"
61
+ }
62
+ ];
27
63
  var CoolifyError = class extends Error {
28
64
  status;
29
65
  constructor(message, status) {
@@ -57,7 +93,7 @@ var CoolifyApi = class {
57
93
  throw new CoolifyError(`Could not reach Coolify at ${this.url}: ${error.message}`, 0);
58
94
  }
59
95
  const text = await response.text();
60
- if (!response.ok) throw new CoolifyError(errorMessage(response.status, text, this.url), response.status);
96
+ if (!response.ok) throw new CoolifyError(errorMessage$1(response.status, text, this.url), response.status);
61
97
  if (!text) return void 0;
62
98
  try {
63
99
  return JSON.parse(text);
@@ -69,7 +105,7 @@ var CoolifyApi = class {
69
105
  post = (path, body) => this.request("POST", path, body ?? {});
70
106
  patch = (path, body) => this.request("PATCH", path, body);
71
107
  };
72
- function errorMessage(status, text, url) {
108
+ function errorMessage$1(status, text, url) {
73
109
  if (status === 401) return `Coolify at ${url} rejected the API key. Run dstack deploy login to enter a new one.`;
74
110
  if (status === 403) return "The API key may not do this. Give it the read, write and deploy permissions in Coolify under Keys & Tokens.";
75
111
  if (status === 404 && text.includes("<html")) return `${url} does not answer like Coolify; check the URL.`;
@@ -131,7 +167,7 @@ function composeDomains(app) {
131
167
  function appUrl(api, link) {
132
168
  return `${api.url}/project/${link.project}/environment/${link.environment}/application/${link.application}`;
133
169
  }
134
- function toDeployment(api, link, item) {
170
+ function toDeployment$1(api, link, item) {
135
171
  const path = item.deployment_url;
136
172
  return {
137
173
  id: item.deployment_uuid,
@@ -145,7 +181,7 @@ function toDeployment(api, link, item) {
145
181
  };
146
182
  }
147
183
  /** Finds a name or uuid in a list. */
148
- function find(items, wanted) {
184
+ function find$1(items, wanted) {
149
185
  return items.find((item) => item.uuid === wanted) ?? items.find((item) => item.name === wanted);
150
186
  }
151
187
  var CoolifyClient = class {
@@ -160,15 +196,16 @@ var CoolifyClient = class {
160
196
  }
161
197
  async setup(context) {
162
198
  const { ask, options } = context;
199
+ if (!context.repository) throw new Error("Coolify builds from a git repository; pass --repository.");
163
200
  const api = this.api;
164
201
  const servers = await api.get("/servers");
165
202
  if (!servers.length) throw new Error("This Coolify has no servers. Add one in Coolify first.");
166
203
  let server;
167
204
  if (options.server) {
168
- server = find(servers, options.server);
205
+ server = find$1(servers, options.server);
169
206
  if (!server) throw new Error(`Coolify has no server ${options.server}.`);
170
207
  } else if (servers.length === 1) server = servers[0];
171
- else server = find(servers, await ask.select("Server to deploy to", servers.map((item) => ({
208
+ else server = find$1(servers, await ask.select("Server to deploy to", servers.map((item) => ({
172
209
  value: item.uuid,
173
210
  label: item.name,
174
211
  hint: item.ip
@@ -177,7 +214,7 @@ var CoolifyClient = class {
177
214
  const projects = await api.get("/projects");
178
215
  let project;
179
216
  const projectName = options.project ?? context.name;
180
- project = find(projects, projectName);
217
+ project = find$1(projects, projectName);
181
218
  if (!options.project && ask.interactive && projects.length) {
182
219
  const NEW = "";
183
220
  const uuid = await ask.select("Coolify project", [...project ? [] : [{
@@ -188,7 +225,7 @@ var CoolifyClient = class {
188
225
  label: item.name,
189
226
  hint: item.description ?? void 0
190
227
  }))], project?.uuid ?? NEW);
191
- project = uuid === NEW ? void 0 : find(projects, uuid);
228
+ project = uuid === NEW ? void 0 : find$1(projects, uuid);
192
229
  }
193
230
  if (!project) {
194
231
  if (options.project && /^[a-z0-9]{20,}$/.test(options.project)) throw new Error(`Coolify has no project ${options.project}.`);
@@ -202,7 +239,7 @@ var CoolifyClient = class {
202
239
  ask.step(`Created the Coolify project ${projectName}`);
203
240
  }
204
241
  const environmentName = options.environment ?? "production";
205
- let environment = find(await api.get(`/projects/${enc(project.uuid)}/environments`), environmentName);
242
+ let environment = find$1(await api.get(`/projects/${enc(project.uuid)}/environments`), environmentName);
206
243
  if (!environment) {
207
244
  environment = {
208
245
  uuid: (await api.post(`/projects/${enc(project.uuid)}/environments`, { name: environmentName })).uuid,
@@ -212,7 +249,7 @@ var CoolifyClient = class {
212
249
  }
213
250
  let githubApp;
214
251
  if (options.githubApp && options.githubApp !== "public") {
215
- const app = find(await api.get("/github-apps"), options.githubApp);
252
+ const app = find$1(await api.get("/github-apps"), options.githubApp);
216
253
  if (!app) throw new Error(`Coolify has no GitHub App ${options.githubApp}.`);
217
254
  githubApp = app.uuid;
218
255
  } else if (!options.githubApp && ask.interactive) {
@@ -281,11 +318,11 @@ var CoolifyClient = class {
281
318
  return id;
282
319
  }
283
320
  async deployment(link, id) {
284
- return toDeployment(this.api, link, await this.api.get(`/deployments/${enc(id)}`));
321
+ return toDeployment$1(this.api, link, await this.api.get(`/deployments/${enc(id)}`));
285
322
  }
286
323
  async deployments(link, limit) {
287
324
  const result = await this.api.get(`/deployments/applications/${enc(link.application)}?take=${limit}`);
288
- return (Array.isArray(result) ? result : result?.deployments ?? []).map((item) => toDeployment(this.api, link, item));
325
+ return (Array.isArray(result) ? result : result?.deployments ?? []).map((item) => toDeployment$1(this.api, link, item));
289
326
  }
290
327
  async status(link) {
291
328
  const app = await this.api.get(`/applications/${enc(link.application)}`);
@@ -301,28 +338,729 @@ var CoolifyClient = class {
301
338
  const coolify = {
302
339
  id: "coolify",
303
340
  name: "Coolify",
304
- tokenEnv: "DSTACK_COOLIFY_TOKEN",
341
+ auth: {
342
+ kind: "token",
343
+ env: "DSTACK_COOLIFY_TOKEN",
344
+ help: (url) => `Create one in Coolify under Keys & Tokens › API tokens (${url}/security/api-tokens) with the read, write and deploy permissions. API access must be on under Settings › Advanced.`
345
+ },
346
+ source: "remote",
347
+ target: urlTarget("Coolify", "https://coolify.example.com"),
305
348
  platformEnv: PLATFORM_ENV,
306
- tokenHelp: (url) => `Create one in Coolify under Keys & Tokens › API tokens (${url}/security/api-tokens) with the read, write and deploy permissions. API access must be on under Settings › Advanced.`,
307
- setupFlags: [
308
- {
309
- name: "server",
310
- description: "The Coolify server, by name or uuid"
311
- },
312
- {
313
- name: "project",
314
- description: "The Coolify project, by name or uuid (default: the project name)"
315
- },
316
- {
317
- name: "environment",
318
- description: "The Coolify environment (default: production)"
319
- },
320
- {
321
- name: "githubApp",
322
- description: "A Coolify GitHub App for a private repository, or public"
349
+ setupFlags: [...SHARED_SETUP_FLAGS, {
350
+ name: "githubApp",
351
+ description: "A Coolify GitHub App for a private repository, or public"
352
+ }],
353
+ connect: (url, token) => {
354
+ if (!token) throw new Error("Coolify needs an API key.");
355
+ return new CoolifyClient(url, token);
356
+ }
357
+ };
358
+ const systemSsh = (args, input) => new Promise((resolve, reject) => {
359
+ const child = spawn("ssh", args, { stdio: [
360
+ "pipe",
361
+ "pipe",
362
+ "pipe"
363
+ ] });
364
+ const stdout = [];
365
+ const stderr = [];
366
+ child.stdout.on("data", (chunk) => stdout.push(chunk));
367
+ child.stderr.on("data", (chunk) => stderr.push(chunk));
368
+ child.on("error", (error) => reject(/* @__PURE__ */ new Error(error.code === "ENOENT" ? "There is no ssh command here. Install OpenSSH." : `Could not run ssh: ${error.message}`)));
369
+ child.on("close", (code) => resolve({
370
+ code: code ?? 1,
371
+ stdout: Buffer.concat(stdout).toString("utf8"),
372
+ stderr: Buffer.concat(stderr).toString("utf8")
373
+ }));
374
+ child.stdin.on("error", () => {});
375
+ child.stdin.end(input);
376
+ });
377
+ const USER = /^[a-z_][\w.-]*$/i;
378
+ const HOST = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?)(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$|^\[[0-9a-f:.]+\]$/i;
379
+ /** Reads deploy@example.com, example.com:2222 or ssh://deploy@example.com:2222. */
380
+ function parseTarget(value) {
381
+ const match = value.trim().replace(/^ssh:\/\//i, "").replace(/\/$/, "").match(/^(?:([^@\s]+)@)?(\[[^\]]+\]|[^:@\s]+)(?::(\d+))?$/);
382
+ const reason = `${value} is not an SSH target. Enter one such as deploy@example.com or deploy@example.com:2222.`;
383
+ if (!match) throw new Error(reason);
384
+ const [, user, host = "", port] = match;
385
+ if (user && !USER.test(user) || !HOST.test(host)) throw new Error(reason);
386
+ const number = port ? Number(port) : void 0;
387
+ if (number !== void 0 && (number < 1 || number > 65535)) throw new Error(reason);
388
+ return {
389
+ user,
390
+ host,
391
+ port: number
392
+ };
393
+ }
394
+ function formatTarget(target) {
395
+ return `ssh://${target.user ? `${target.user}@` : ""}${target.host}${target.port ? `:${target.port}` : ""}`;
396
+ }
397
+ /** Quotes a value for the remote POSIX shell. */
398
+ function shellQuote(value) {
399
+ return `'${value.replace(/'/g, `'\\''`)}'`;
400
+ }
401
+ /** A Compose project name: lower case letters, digits, dashes and underscores. */
402
+ function composeName(name) {
403
+ return name.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^[^a-z0-9]+|-+$/g, "") || "site";
404
+ }
405
+ /** A deploy id that sorts by time: 20261105-183000-abc1234. */
406
+ function deployId(date, suffix) {
407
+ const pad = (value) => String(value).padStart(2, "0");
408
+ return `${`${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}`}-${`${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}`}-${suffix}`;
409
+ }
410
+ const ID = /^[\w.-]+$/;
411
+ /** The status file: building, finished or failed, and whether the deploy's process lives. */
412
+ function recordStatus(status, alive) {
413
+ const value = status.trim();
414
+ if (value === "finished") return "finished";
415
+ if (value === "failed") return "failed";
416
+ if (value === "queued") return alive ? "queued" : "failed";
417
+ if (value === "building") return alive ? "building" : "failed";
418
+ return "failed";
419
+ }
420
+ /** `docker compose ps --format json` prints an array (older) or one object per line (newer). */
421
+ function parseComposePs(output) {
422
+ const text = output.trim();
423
+ if (!text) return [];
424
+ if (text.startsWith("[")) return JSON.parse(text);
425
+ return text.split("\n").filter((line) => line.trim()).map((line) => JSON.parse(line));
426
+ }
427
+ function composeState(containers) {
428
+ if (!containers.length) return {
429
+ state: "stopped",
430
+ detail: "no containers"
431
+ };
432
+ const running = containers.filter((item) => item.State === "running");
433
+ const unhealthy = containers.filter((item) => item.Health === "unhealthy" || item.State === "restarting");
434
+ const summary = `${running.length}/${containers.length} running`;
435
+ if (!running.length) return {
436
+ state: "stopped",
437
+ detail: summary
438
+ };
439
+ if (unhealthy.length) return {
440
+ state: "degraded",
441
+ detail: `${summary}, unhealthy: ${unhealthy.map((item) => item.Service).join(", ")}`
442
+ };
443
+ if (running.length < containers.length) return {
444
+ state: "degraded",
445
+ detail: `${summary}, not running: ${containers.filter((item) => item.State !== "running").map((item) => item.Service).join(", ")}`
446
+ };
447
+ return {
448
+ state: "running",
449
+ detail: summary
450
+ };
451
+ }
452
+ /** Splits the output of a remote command whose parts are separated by MARK lines. */
453
+ const MARK = "--dstack-8f2c--";
454
+ function parts(output) {
455
+ return output.split(`\n${MARK}\n`);
456
+ }
457
+ /**
458
+ * The script that runs a deploy on the server, detached. It builds and
459
+ * starts the release, moves `current` to it on success and removes the
460
+ * oldest releases beyond KEEP_RELEASES.
461
+ */
462
+ function deployScript(path, project, id, release, force) {
463
+ const q = shellQuote;
464
+ const compose = `docker compose -p ${q(project)}`;
465
+ return `#!/bin/sh
466
+ set -u
467
+ app=${q(path)}
468
+ status="$app/deploys/${id}.status"
469
+ trap 'rm -rf "$app/deploy.lock"' EXIT
470
+ cd "$app/releases/${release}" || { echo failed > "$status"; exit 1; }
471
+ echo building > "$status"
472
+ echo "Deploy ${id}: release ${release}"
473
+ ${force ? `${compose} build --no-cache --pull && ` : ""}${compose} up -d --build --remove-orphans --wait --wait-timeout 600
474
+ code=$?
475
+ if [ "$code" -ne 0 ]; then
476
+ echo "docker compose exited with $code"
477
+ ${compose} ps --all
478
+ echo failed > "$status"
479
+ exit "$code"
480
+ fi
481
+ ln -sfn "releases/${release}" "$app/current"
482
+ cd "$app/releases"
483
+ ls -1 | sort -r | tail -n +6 | while read -r old; do
484
+ [ "$old" = ${q(release)} ] || rm -rf -- "$old"
485
+ done
486
+ echo "Started release ${release}"
487
+ echo finished > "$status"
488
+ `;
489
+ }
490
+ var SshClient = class {
491
+ run;
492
+ git;
493
+ target;
494
+ constructor(target, run = systemSsh, git = defaultGit) {
495
+ this.run = run;
496
+ this.git = git;
497
+ this.target = parseTarget(target);
498
+ }
499
+ get destination() {
500
+ return `${this.target.user ? `${this.target.user}@` : ""}${this.target.host.replace(/^\[|\]$/g, "")}`;
501
+ }
502
+ /** Runs a POSIX shell script on the server; fails with a one-line reason. */
503
+ async sh(script, input) {
504
+ const args = [
505
+ "-o",
506
+ "BatchMode=yes",
507
+ "-o",
508
+ "ConnectTimeout=15",
509
+ ...this.target.port ? ["-p", String(this.target.port)] : [],
510
+ "--",
511
+ this.destination,
512
+ `sh -c ${shellQuote(script)}`
513
+ ];
514
+ const result = await this.run(args, input);
515
+ if (result.code === 0) return result.stdout;
516
+ throw new Error(this.errorMessage(result));
517
+ }
518
+ errorMessage(result) {
519
+ const stderr = result.stderr.trim();
520
+ const where = formatTarget(this.target);
521
+ if (/REMOTE HOST IDENTIFICATION HAS CHANGED/i.test(stderr)) return `The host key of ${where} changed. If you reinstalled the server, remove the old key with ssh-keygen -R ${this.target.host}; otherwise do not connect.`;
522
+ if (/Host key verification failed|No .* host key is known/i.test(stderr)) return `${where} is not in your known_hosts yet. Connect once with ssh ${this.destination}${this.target.port ? ` -p ${this.target.port}` : ""}, check the fingerprint and answer yes, then try again.`;
523
+ if (/Permission denied/i.test(stderr)) return `${where} refused your SSH key. Add it with ssh-copy-id ${this.destination}, or load it into the agent with ssh-add.`;
524
+ if (result.code === 255) return `Could not connect to ${where}: ${stderr.split("\n").at(-1) || "ssh failed"}`;
525
+ const last = (stderr || result.stdout.trim()).split("\n").at(-1) ?? "";
526
+ return `The command on ${where} failed (exit ${result.code})${last ? `: ${last}` : "."}`;
527
+ }
528
+ /** Checks the connection and Docker; returns their versions. */
529
+ async verify() {
530
+ const output = await this.sh(`
531
+ command -v docker >/dev/null 2>&1 || { echo missing:docker; exit 0; }
532
+ docker compose version --short 2>/dev/null || echo missing:compose
533
+ docker info --format '{{.ServerVersion}}' 2>/dev/null || echo missing:permission
534
+ `);
535
+ if (output.includes("missing:docker")) throw new Error(`${formatTarget(this.target)} has no Docker. Install Docker Engine with the Compose plugin: https://docs.docker.com/engine/install/`);
536
+ if (output.includes("missing:compose")) throw new Error(`${formatTarget(this.target)} has Docker without the Compose plugin. Install docker-compose-plugin: https://docs.docker.com/compose/install/linux/`);
537
+ if (output.includes("missing:permission")) throw new Error(`${this.destination} may not use Docker on the server. Run sudo usermod -aG docker ${this.target.user ?? "$USER"} there and connect again.`);
538
+ const [compose = "", engine = ""] = output.trim().split("\n");
539
+ return `Docker ${engine.trim()}, Compose ${compose.trim().replace(/^v/, "")}`;
540
+ }
541
+ async setup(context) {
542
+ const { ask, options } = context;
543
+ let path = options.path ?? `/srv/${composeName(context.name)}`;
544
+ if (path.startsWith("~/")) path = `${(await this.sh("printf %s \"$HOME\"")).trim()}/${path.slice(2)}`;
545
+ if (!path.startsWith("/")) throw new Error(`Give --path as an absolute path, such as /srv/${composeName(context.name)}.`);
546
+ path = path.replace(/\/+$/, "");
547
+ const q = shellQuote;
548
+ if (!(await this.sh(`
549
+ if mkdir -p ${q(`${path}/releases`)} ${q(`${path}/shared`)} ${q(`${path}/deploys`)} 2>/dev/null && [ -w ${q(path)} ]; then echo ok; fi
550
+ `)).includes("ok")) {
551
+ const home = (await this.sh("printf %s \"$HOME\"")).trim();
552
+ throw new Error(`${this.destination} cannot write to ${path} on the server. Create it there with sudo mkdir -p ${path} && sudo chown ${this.target.user ?? "$USER"} ${path}, or pass a directory you own, such as --path ${home}/${composeName(context.name)}.`);
553
+ }
554
+ ask.step(`Created ${path} on the server`);
555
+ const project = composeName(context.name);
556
+ const link = {
557
+ project: path,
558
+ environment: "production",
559
+ application: project
560
+ };
561
+ await this.pushEnv({
562
+ provider: "ssh",
563
+ url: formatTarget(this.target),
564
+ ...link
565
+ }, context.env);
566
+ ask.step("Uploaded the variables as shared/.env");
567
+ return link;
568
+ }
569
+ async pushEnv(link, env) {
570
+ const text = Object.entries(env).map(([key, value]) => `${key}=${envValue(value)}`).join("\n");
571
+ const file = shellQuote(`${link.project}/shared/.env`);
572
+ await this.sh(`umask 077 && cat > ${file}.new && chmod 600 ${file}.new && mv ${file}.new ${file}`, `${text}\n`);
573
+ }
574
+ async deploy(link, options) {
575
+ const { directory } = options;
576
+ const [commit = "", message = ""] = this.git([
577
+ "-C",
578
+ directory,
579
+ "log",
580
+ "-1",
581
+ "--format=%h%n%s"
582
+ ]).toString("utf8").split("\n");
583
+ const id = deployId(/* @__PURE__ */ new Date(), commit || "local");
584
+ await this.lock(link, id);
585
+ const archive = this.git([
586
+ "-C",
587
+ directory,
588
+ "archive",
589
+ "--format=tar",
590
+ "HEAD"
591
+ ]);
592
+ const release = shellQuote(`${link.project}/releases/${id}`);
593
+ await this.sh(`mkdir -p ${release} && tar -x -C ${release} && ln -sfn ../../shared/.env ${release}/.env`, archive);
594
+ await this.start(link, {
595
+ id,
596
+ release: id,
597
+ commit,
598
+ message,
599
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
600
+ }, options.force);
601
+ return id;
602
+ }
603
+ async rollback(link, to) {
604
+ const deployments = await this.records(link, 50);
605
+ const current = (await this.sh(`readlink ${shellQuote(`${link.project}/current`)} || true`)).trim().replace(/^releases\//, "");
606
+ const releases = new Set((await this.sh(`ls -1 ${shellQuote(`${link.project}/releases`)}`)).split("\n").filter(Boolean));
607
+ let target;
608
+ if (to) {
609
+ target = deployments.find((item) => item.record.id === to)?.record;
610
+ if (!target) throw new Error(`There is no deploy ${to} on the server.`);
611
+ } else {
612
+ target = deployments.find((item) => item.status === "finished" && item.record.release !== current && releases.has(item.record.release))?.record;
613
+ if (!target) throw new Error("There is no earlier release on the server to roll back to.");
614
+ }
615
+ if (!releases.has(target.release)) throw new Error(`The release of ${target.id} is no longer on the server; only the last 5 are kept.`);
616
+ const id = deployId(/* @__PURE__ */ new Date(), "rollback");
617
+ await this.lock(link, id);
618
+ await this.start(link, {
619
+ id,
620
+ release: target.release,
621
+ commit: target.commit,
622
+ message: `Rollback to ${target.id}`,
623
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
624
+ rollbackOf: target.id
625
+ }, false);
626
+ return id;
627
+ }
628
+ /** Takes the server's deploy lock, or fails when a deploy is running. */
629
+ async lock(link, id) {
630
+ const lock = shellQuote(`${link.project}/deploy.lock`);
631
+ const output = await this.sh(`
632
+ if mkdir ${lock} 2>/dev/null; then echo ${id} > ${lock}/id; echo ok; exit 0; fi
633
+ pid=$(cat ${lock}/pid 2>/dev/null)
634
+ if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then echo "busy:$(cat ${lock}/id 2>/dev/null)"; exit 0; fi
635
+ rm -rf ${lock} && mkdir ${lock} && echo ${id} > ${lock}/id && echo ok
636
+ `);
637
+ const busy = output.match(/busy:(\S*)/);
638
+ if (busy) throw new Error(`Deploy ${busy[1]} is still running on the server. Follow it with dstack deploy status --watch.`);
639
+ if (!output.includes("ok")) throw new Error("Could not take the deploy lock on the server.");
640
+ }
641
+ async start(link, record, force) {
642
+ if (!ID.test(record.id) || !ID.test(record.release)) throw new Error(`Invalid deploy id ${record.id}.`);
643
+ const deploys = `${link.project}/deploys`;
644
+ const q = shellQuote;
645
+ const script = deployScript(link.project, link.application, record.id, record.release, force);
646
+ await this.sh(`
647
+ cd ${q(deploys)} || exit 1
648
+ cat > ${q(`${record.id}.sh`)}
649
+ printf '%s\\n' ${q(JSON.stringify(record))} > ${q(`${record.id}.json`)}
650
+ echo queued > ${q(`${record.id}.status`)}
651
+ nohup sh ${q(`${record.id}.sh`)} > ${q(`${record.id}.log`)} 2>&1 < /dev/null &
652
+ echo $! > ${q(`${record.id}.pid`)}
653
+ echo $! > ${q(`${link.project}/deploy.lock/pid`)}
654
+ `, script);
655
+ }
656
+ /** The newest deploy records first, with their status. */
657
+ async records(link, limit) {
658
+ const output = await this.sh(`
659
+ cd ${shellQuote(`${link.project}/deploys`)} 2>/dev/null || exit 0
660
+ for file in $(ls -1 *.json 2>/dev/null | sort -r | head -n ${Math.max(1, Math.floor(limit))}); do
661
+ id="\${file%.json}"
662
+ cat "$file"
663
+ pid=$(cat "$id.pid" 2>/dev/null)
664
+ if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then alive=1; else alive=0; fi
665
+ printf '%s %s %s\\n' "$(cat "$id.status" 2>/dev/null || echo failed)" "$alive" "$(date -u -r "$id.status" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)"
666
+ echo ${MARK}
667
+ done
668
+ `);
669
+ const items = [];
670
+ for (const part of parts(`\n${output}`)) {
671
+ const lines = part.trim().split("\n");
672
+ if (lines.length < 2) continue;
673
+ try {
674
+ const record = JSON.parse(lines[0] ?? "");
675
+ const [status = "", alive = "0", updatedAt] = (lines[1] ?? "").split(" ");
676
+ items.push({
677
+ record,
678
+ status: recordStatus(status, alive === "1"),
679
+ updatedAt: updatedAt || void 0
680
+ });
681
+ } catch {}
682
+ }
683
+ return items;
684
+ }
685
+ async deployment(link, id) {
686
+ if (!ID.test(id)) throw new Error(`Invalid deploy id ${id}.`);
687
+ const deploys = shellQuote(`${link.project}/deploys`);
688
+ const output = await this.sh(`
689
+ cd ${deploys} || exit 1
690
+ [ -f ${shellQuote(`${id}.json`)} ] || { echo missing; exit 0; }
691
+ cat ${shellQuote(`${id}.json`)}
692
+ pid=$(cat ${shellQuote(`${id}.pid`)} 2>/dev/null)
693
+ if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then alive=1; else alive=0; fi
694
+ printf '%s %s %s\\n' "$(cat ${shellQuote(`${id}.status`)} 2>/dev/null || echo failed)" "$alive" "$(date -u -r ${shellQuote(`${id}.status`)} +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)"
695
+ echo ${MARK}
696
+ cat ${shellQuote(`${id}.log`)} 2>/dev/null
697
+ `);
698
+ if (output.trim() === "missing") throw new Error(`There is no deploy ${id} on the server.`);
699
+ const [head = "", log = ""] = output.split(`\n${MARK}\n`);
700
+ const [json = "{}", state = ""] = head.split("\n");
701
+ const record = JSON.parse(json);
702
+ const [status = "", alive = "0", updatedAt] = state.split(" ");
703
+ return {
704
+ ...toDeployment(record, recordStatus(status, alive === "1"), updatedAt || void 0),
705
+ log: log ? log.replace(/\n$/, "").split("\n").map((text) => ({
706
+ text,
707
+ stderr: false
708
+ })) : []
709
+ };
710
+ }
711
+ async deployments(link, limit) {
712
+ return (await this.records(link, limit)).map((item) => ({
713
+ ...toDeployment(item.record, item.status, item.updatedAt),
714
+ log: []
715
+ }));
716
+ }
717
+ async status(link) {
718
+ const [ps = "", site = ""] = parts(await this.sh(`
719
+ docker compose -p ${shellQuote(link.application)} ps --all --format json
720
+ echo ${MARK}
721
+ sed -n 's/^SITE_URL=//p' ${shellQuote(`${link.project}/shared/.env`)} 2>/dev/null
722
+ `));
723
+ const { state, detail } = composeState(parseComposePs(ps));
724
+ const domain = site.trim().replace(/^["']|["']$/g, "");
725
+ return {
726
+ state,
727
+ detail,
728
+ domains: domain ? [domain] : []
729
+ };
730
+ }
731
+ };
732
+ function toDeployment(record, status, updatedAt) {
733
+ return {
734
+ id: record.id,
735
+ status,
736
+ commit: record.commit,
737
+ message: record.message,
738
+ createdAt: record.createdAt,
739
+ updatedAt
740
+ };
741
+ }
742
+ /**
743
+ * A value for a Compose .env line: bare when safe, else single-quoted (taken
744
+ * literally), or double-quoted with escapes when it holds a single quote.
745
+ */
746
+ function envValue(value) {
747
+ if (/^[\w@%+=:,./-]*$/.test(value)) return value;
748
+ if (!value.includes("'") && !value.includes("\n")) return `'${value}'`;
749
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\$/g, "$$$$").replace(/\n/g, "\\n")}"`;
750
+ }
751
+ function defaultGit(args) {
752
+ return execFileSync("git", args, {
753
+ maxBuffer: 1073741824,
754
+ stdio: [
755
+ "ignore",
756
+ "pipe",
757
+ "pipe"
758
+ ]
759
+ });
760
+ }
761
+ const ssh = {
762
+ id: "ssh",
763
+ name: "SSH server",
764
+ auth: { kind: "ssh" },
765
+ source: "local",
766
+ target: {
767
+ label: "Server",
768
+ placeholder: "deploy@example.com",
769
+ normalize: (value) => formatTarget(parseTarget(value))
770
+ },
771
+ platformEnv: {},
772
+ setupFlags: [{
773
+ name: "path",
774
+ description: "The project directory on the server (default: /srv/<project>)"
775
+ }],
776
+ connect: (target) => new SshClient(target)
777
+ };
778
+ // src/deploy/dokploy.ts
779
+ var DokployError = class extends Error {
780
+ status;
781
+ constructor(message, status) {
782
+ super(message);
783
+ this.status = status;
784
+ }
785
+ };
786
+ var DokployApi = class {
787
+ url;
788
+ token;
789
+ fetchImpl;
790
+ constructor(url, token, fetchImpl = fetch) {
791
+ this.url = url;
792
+ this.token = token;
793
+ this.fetchImpl = fetchImpl;
794
+ }
795
+ async request(method, procedure, input) {
796
+ let path = `${this.url}/api/${procedure}`;
797
+ if (method === "GET" && input) {
798
+ const query = new URLSearchParams();
799
+ for (const [key, value] of Object.entries(input)) if (value !== void 0) query.set(key, String(value));
800
+ path += `?${query}`;
801
+ }
802
+ let response;
803
+ try {
804
+ response = await this.fetchImpl(path, {
805
+ method,
806
+ headers: {
807
+ "x-api-key": this.token,
808
+ Accept: "application/json",
809
+ ...method === "POST" ? { "Content-Type": "application/json" } : {}
810
+ },
811
+ body: method === "POST" ? JSON.stringify(input ?? {}) : void 0,
812
+ signal: AbortSignal.timeout(3e4)
813
+ });
814
+ } catch (error) {
815
+ throw new DokployError(`Could not reach Dokploy at ${this.url}: ${error.message}`, 0);
816
+ }
817
+ const text = await response.text();
818
+ if (!response.ok) throw new DokployError(errorMessage(response.status, text, this.url), response.status);
819
+ if (!text) return void 0;
820
+ try {
821
+ return JSON.parse(text);
822
+ } catch {
823
+ return text;
824
+ }
825
+ }
826
+ get = (procedure, input) => this.request("GET", procedure, input);
827
+ post = (procedure, input) => this.request("POST", procedure, input);
828
+ };
829
+ function errorMessage(status, text, url) {
830
+ if (status === 401) return `Dokploy at ${url} rejected the API key. Run dstack deploy login to enter a new one.`;
831
+ if (status === 403) return "The API key may not do this. Create it as a user who may manage projects in Dokploy.";
832
+ if (status === 404 && text.includes("<html")) return `${url} does not answer like Dokploy; check the URL.`;
833
+ let message = "";
834
+ try {
835
+ const body = JSON.parse(text);
836
+ message = body.message ?? body.error?.message ?? "";
837
+ } catch {
838
+ message = text.slice(0, 200);
839
+ }
840
+ return `Dokploy answered ${status}${message ? `: ${message.trim()}` : "."}`;
841
+ }
842
+ /** A deployment's status: running, done, error or cancelled in Dokploy. */
843
+ function dokployDeploymentStatus(value) {
844
+ if (value === "done") return "finished";
845
+ if (value === "running") return "building";
846
+ if (value === "queued" || value === "idle") return "queued";
847
+ if (value === "cancelled") return "cancelled";
848
+ return "failed";
849
+ }
850
+ /** The service state from the compose status, which tracks the last deploy. */
851
+ function dokployAppState(value) {
852
+ if (value === "done") return "running";
853
+ if (value === "error") return "degraded";
854
+ if (value === "idle") return "stopped";
855
+ return "unknown";
856
+ }
857
+ /** A .env text for Dokploy, which writes it next to compose.yaml. */
858
+ function envText(env) {
859
+ return Object.entries(env).map(([key, value]) => `${key}=${envValue(value)}`).join("\n");
860
+ }
861
+ function composeUrl(api, link) {
862
+ return `${api.url}/dashboard/project/${link.project}/environment/${link.environment}/services/compose/${link.application}`;
863
+ }
864
+ /** Finds a name or id in a list. */
865
+ function find(items, wanted, id, name) {
866
+ return items.find((item) => id(item) === wanted) ?? items.find((item) => name(item) === wanted);
867
+ }
868
+ const LOCAL_SERVER = "dokploy";
869
+ var DokployClient = class {
870
+ api;
871
+ constructor(url, token, fetchImpl) {
872
+ this.api = new DokployApi(url, token, fetchImpl);
873
+ }
874
+ async verify() {
875
+ await this.api.get("project.all");
876
+ const version = await this.api.get("settings.getDokployVersion").catch(() => void 0);
877
+ return typeof version === "string" && version.trim() ? version.trim() : "(version unknown)";
878
+ }
879
+ async setup(context) {
880
+ const { ask, options } = context;
881
+ if (!context.repository) throw new Error("Dokploy builds from a git repository; pass --repository.");
882
+ const api = this.api;
883
+ const servers = await api.get("server.all").catch(() => []) ?? [];
884
+ let serverId;
885
+ if (options.server && options.server !== LOCAL_SERVER) {
886
+ const server = find(servers, options.server, (item) => item.serverId, (item) => item.name);
887
+ if (!server) throw new Error(`Dokploy has no server ${options.server}.`);
888
+ serverId = server.serverId;
889
+ } else if (!options.server && servers.length && ask.interactive) {
890
+ const choice = await ask.select("Server to deploy to", [{
891
+ value: LOCAL_SERVER,
892
+ label: "The Dokploy server"
893
+ }, ...servers.map((item) => ({
894
+ value: item.serverId,
895
+ label: item.name,
896
+ hint: item.ipAddress
897
+ }))], LOCAL_SERVER);
898
+ serverId = choice === LOCAL_SERVER ? void 0 : choice;
899
+ }
900
+ const projects = await api.get("project.all") ?? [];
901
+ const projectName = options.project ?? context.name;
902
+ let project = find(projects, projectName, (item) => item.projectId, (item) => item.name);
903
+ if (!options.project && ask.interactive && projects.length) {
904
+ const NEW = "";
905
+ const id = await ask.select("Dokploy project", [...project ? [] : [{
906
+ value: NEW,
907
+ label: `Create ${projectName}`
908
+ }], ...projects.map((item) => ({
909
+ value: item.projectId,
910
+ label: item.name,
911
+ hint: item.description ?? void 0
912
+ }))], project?.projectId ?? NEW);
913
+ project = id === NEW ? void 0 : find(projects, id, (item) => item.projectId, (item) => item.name);
914
+ }
915
+ if (!project) {
916
+ const created = await api.post("project.create", {
917
+ name: projectName,
918
+ description: "DjangoStack"
919
+ });
920
+ const projectId = created?.project?.projectId ?? created?.projectId;
921
+ if (!projectId) throw new Error("Dokploy created the project but did not return its id.");
922
+ project = {
923
+ projectId,
924
+ name: projectName
925
+ };
926
+ ask.step(`Created the Dokploy project ${projectName}`);
927
+ }
928
+ const environmentName = options.environment ?? "production";
929
+ let environment = find(await api.get("environment.byProjectId", { projectId: project.projectId }).catch((error) => {
930
+ if (error.status === 404) throw new Error("This Dokploy has no environments; update Dokploy to a version with environments.");
931
+ throw error;
932
+ }) ?? [], environmentName, (item) => item.environmentId, (item) => item.name);
933
+ if (!environment) {
934
+ const environmentId = (await api.post("environment.create", {
935
+ name: environmentName,
936
+ projectId: project.projectId
937
+ }))?.environmentId ?? (await api.get("environment.byProjectId", { projectId: project.projectId })).find((item) => item.name === environmentName)?.environmentId;
938
+ if (!environmentId) throw new Error("Dokploy created the environment but did not return its id.");
939
+ environment = {
940
+ environmentId,
941
+ name: environmentName
942
+ };
943
+ ask.step(`Created the environment ${environmentName}`);
944
+ }
945
+ let composeId = (await api.post("compose.create", {
946
+ name: context.name,
947
+ environmentId: environment.environmentId,
948
+ projectId: project.projectId,
949
+ composeType: "docker-compose",
950
+ description: "DjangoStack",
951
+ ...serverId ? { serverId } : {}
952
+ }))?.composeId;
953
+ if (!composeId) composeId = ((await api.get("environment.one", { environmentId: environment.environmentId }))?.compose ?? []).filter((item) => item.name === context.name).at(-1)?.composeId;
954
+ if (!composeId) throw new Error("Dokploy created the compose service but did not return its id.");
955
+ await api.post("compose.update", {
956
+ composeId,
957
+ sourceType: "git",
958
+ customGitUrl: context.repository,
959
+ customGitBranch: context.branch,
960
+ composePath: "./compose.yaml",
961
+ env: envText(context.env)
962
+ });
963
+ ask.step(`Created the compose service ${context.name} from ${context.repository} (${context.branch})`);
964
+ if (!(await api.get("domain.byComposeId", { composeId }).catch(() => []) ?? []).some((item) => item.host === context.domain)) await api.post("domain.create", {
965
+ host: context.domain,
966
+ path: "/",
967
+ port: 80,
968
+ https: true,
969
+ certificateType: "letsencrypt",
970
+ composeId,
971
+ serviceName: "proxy",
972
+ domainType: "compose"
973
+ });
974
+ ask.step(`Gave the proxy service https://${context.domain}`);
975
+ return {
976
+ project: project.projectId,
977
+ environment: environment.environmentId,
978
+ application: composeId
979
+ };
980
+ }
981
+ async pushEnv(link, env) {
982
+ await this.api.post("compose.update", {
983
+ composeId: link.application,
984
+ env: envText(env)
985
+ });
986
+ }
987
+ async deploy(link) {
988
+ const before = new Set((await this.raw(link)).map((item) => item.deploymentId));
989
+ await this.api.post("compose.deploy", {
990
+ composeId: link.application,
991
+ title: "dstack deploy"
992
+ });
993
+ for (let attempt = 0; attempt < 20; attempt++) {
994
+ const fresh = (await this.raw(link)).find((item) => !before.has(item.deploymentId));
995
+ if (fresh) return fresh.deploymentId;
996
+ await new Promise((resolve) => setTimeout(resolve, this.pollMs));
323
997
  }
324
- ],
325
- connect: (url, token) => new CoolifyClient(url, token)
998
+ throw new Error(`Dokploy took the deploy but shows no deployment for it yet. See ${composeUrl(this.api, link)}`);
999
+ }
1000
+ /** How long `deploy` waits between looks for the new deployment; tests set it to 0. */
1001
+ pollMs = 1e3;
1002
+ /** The deployments, newest first. */
1003
+ async raw(link) {
1004
+ return [...await this.api.get("deployment.allByCompose", { composeId: link.application }) ?? []].sort((a, b) => String(b.createdAt ?? "").localeCompare(String(a.createdAt ?? "")));
1005
+ }
1006
+ toDeployment(link, item) {
1007
+ const status = dokployDeploymentStatus(item.status);
1008
+ const url = composeUrl(this.api, link);
1009
+ return {
1010
+ id: item.deploymentId,
1011
+ status,
1012
+ message: item.title && item.title !== "dstack deploy" ? item.title : (item.description ?? void 0) || void 0,
1013
+ createdAt: item.createdAt,
1014
+ updatedAt: item.finishedAt ?? item.startedAt ?? void 0,
1015
+ url,
1016
+ log: [...item.errorMessage ? [{
1017
+ text: item.errorMessage,
1018
+ stderr: true
1019
+ }] : [], {
1020
+ text: `Dokploy shows the build log in its dashboard: ${url} (Deployments)`,
1021
+ stderr: false
1022
+ }]
1023
+ };
1024
+ }
1025
+ async deployment(link, id) {
1026
+ const item = (await this.raw(link)).find((deployment) => deployment.deploymentId === id);
1027
+ if (!item) throw new Error(`Dokploy has no deployment ${id} for this service.`);
1028
+ return this.toDeployment(link, item);
1029
+ }
1030
+ async deployments(link, limit) {
1031
+ return (await this.raw(link)).slice(0, limit).map((item) => ({
1032
+ ...this.toDeployment(link, item),
1033
+ log: []
1034
+ }));
1035
+ }
1036
+ async status(link) {
1037
+ const compose = await this.api.get("compose.one", { composeId: link.application });
1038
+ const domains = (compose.domains ?? []).map((item) => `${item.https === false ? "http" : "https"}://${item.host}`);
1039
+ return {
1040
+ state: dokployAppState(compose.composeStatus),
1041
+ detail: compose.composeStatus ?? "unknown",
1042
+ domains,
1043
+ branch: compose.customGitBranch ?? void 0,
1044
+ url: composeUrl(this.api, link)
1045
+ };
1046
+ }
1047
+ };
1048
+ const dokploy = {
1049
+ id: "dokploy",
1050
+ name: "Dokploy",
1051
+ auth: {
1052
+ kind: "token",
1053
+ env: "DSTACK_DOKPLOY_TOKEN",
1054
+ help: (url) => `Create one in Dokploy under Settings › Profile › API/CLI (${url}/dashboard/settings/profile), as a user who may create projects.`
1055
+ },
1056
+ source: "remote",
1057
+ target: urlTarget("Dokploy", "https://dokploy.example.com"),
1058
+ platformEnv: PLATFORM_ENV,
1059
+ setupFlags: SHARED_SETUP_FLAGS,
1060
+ connect: (url, token) => {
1061
+ if (!token) throw new Error("Dokploy needs an API key.");
1062
+ return new DokployClient(url, token);
1063
+ }
326
1064
  };
327
1065
  // src/deploy/credentials.ts
328
1066
  /**
@@ -523,23 +1261,27 @@ function unpushedReason(directory, branch) {
523
1261
  `refs/remotes/origin/${branch}`
524
1262
  ]) === void 0) return `Your last commit is not on origin/${branch}, so the deploy would not include it. Push first: git push`;
525
1263
  }
526
- // src/deploy/provider.ts
527
- /** Normalizes an instance URL: https by default, no trailing slash or path. */
528
- function normalizeUrl(value) {
529
- const trimmed = value.trim();
530
- const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
531
- let parsed;
532
- try {
533
- parsed = new URL(withScheme);
534
- } catch {
535
- throw new Error(`${value} is not a URL. Enter one such as https://coolify.example.com.`);
536
- }
537
- return `${parsed.protocol}//${parsed.host}`;
1264
+ /**
1265
+ * Why an upload of HEAD would not match the working tree, or undefined when
1266
+ * it does. Providers that upload the local commit deploy HEAD only, so
1267
+ * changes to tracked files must be committed first.
1268
+ */
1269
+ function uncommittedReason(directory) {
1270
+ if (git(directory, ["rev-parse", "HEAD"]) === void 0) return "This project has no commit yet. Commit it first: git add -A && git commit";
1271
+ if (git(directory, [
1272
+ "status",
1273
+ "--porcelain",
1274
+ "--untracked-files=no"
1275
+ ])) return "You have uncommitted changes, so the deploy would not include them. Commit first.";
538
1276
  }
539
1277
  // src/deploy/index.ts
540
1278
  const DEFAULT_PROVIDER = coolify;
541
1279
  /** Every deploy provider. */
542
- const PROVIDERS = [coolify];
1280
+ const PROVIDERS = [
1281
+ coolify,
1282
+ dokploy,
1283
+ ssh
1284
+ ];
543
1285
  function getProvider(id) {
544
1286
  const provider = PROVIDERS.find((item) => item.id === id);
545
1287
  if (!provider) throw new Error(`Unknown deploy provider ${id}. Choose one of: ${PROVIDERS.map((p) => p.id).join(", ")}.`);
@@ -822,8 +1564,13 @@ function deployEnv(directory, provider) {
822
1564
  ...provider.platformEnv
823
1565
  };
824
1566
  }
1567
+ /** The API key settings of a provider, or a one-line failure for one that uses SSH. */
1568
+ function tokenAuth(provider) {
1569
+ if (provider.auth.kind !== "token") fail(`${provider.name} uses your SSH keys and ~/.ssh/config; there is no API key to save.`);
1570
+ return provider.auth;
1571
+ }
825
1572
  async function askToken(provider, instance) {
826
- p.log.info(provider.tokenHelp(instance));
1573
+ p.log.info(tokenAuth(provider).help(instance));
827
1574
  return unwrap(await p.password({
828
1575
  message: `${provider.name} API key`,
829
1576
  validate: (value) => value?.trim() ? void 0 : "Paste the API key."
@@ -836,14 +1583,15 @@ async function login(provider, instance, token) {
836
1583
  const version = await attempt(provider.connect(instance, token).verify(), spinner);
837
1584
  spinner.stop(`${provider.name} ${version} accepts the key`);
838
1585
  if (saveToken(provider.id, instance, token) === "keychain") p.log.success("Saved the key in the system keychain.");
839
- else p.log.warn(`There is no system keychain here, so the key is in ~/.config/dstack/credentials.json, readable only by you. Set ${provider.tokenEnv} instead to keep it off disk.`);
1586
+ else p.log.warn(`There is no system keychain here, so the key is in ~/.config/dstack/credentials.json, readable only by you. Set ${tokenAuth(provider).env} instead to keep it off disk.`);
840
1587
  return token;
841
1588
  }
842
- /** A client for a provider instance: the saved key, or a new one when the CLI may ask. */
1589
+ /** A client for a provider target: the saved key, or a new one when the CLI may ask. */
843
1590
  async function connect(provider, instance, interactive) {
844
- const saved = getToken(provider.id, instance, provider.tokenEnv);
1591
+ if (provider.auth.kind === "ssh") return attempt(Promise.resolve().then(() => provider.connect(instance)));
1592
+ const saved = getToken(provider.id, instance, provider.auth.env);
845
1593
  if (saved) return provider.connect(instance, saved.token);
846
- if (!interactive) fail(`No ${provider.name} API key for ${instance}. Run ${command("dstack deploy login")} or set ${provider.tokenEnv}.`);
1594
+ if (!interactive) fail(`No ${provider.name} API key for ${instance}. Run ${command("dstack deploy login")} or set ${provider.auth.env}.`);
847
1595
  const token = await login(provider, instance, await askToken(provider, instance));
848
1596
  return provider.connect(instance, token);
849
1597
  }
@@ -906,11 +1654,11 @@ async function deploySetup(options) {
906
1654
  if (!instance) {
907
1655
  if (!interactive) fail(`Pass --url, the address of your ${provider.name}.`);
908
1656
  instance = unwrap(await p.text({
909
- message: `${provider.name} URL`,
910
- placeholder: "https://coolify.example.com",
1657
+ message: provider.target.label,
1658
+ placeholder: provider.target.placeholder,
911
1659
  validate: (value) => {
912
1660
  try {
913
- normalizeUrl(value ?? "");
1661
+ provider.target.normalize(value ?? "");
914
1662
  } catch (error) {
915
1663
  return error.message;
916
1664
  }
@@ -918,7 +1666,7 @@ async function deploySetup(options) {
918
1666
  }));
919
1667
  }
920
1668
  try {
921
- instance = normalizeUrl(instance);
1669
+ instance = provider.target.normalize(instance);
922
1670
  } catch (error) {
923
1671
  fail(error.message);
924
1672
  }
@@ -926,15 +1674,17 @@ async function deploySetup(options) {
926
1674
  const domain = (env.SITE_URL ?? "").replace(/^https?:\/\//, "").replace(/\/.*$/, "");
927
1675
  if (!domain || domain.endsWith("localhost")) fail(`SITE_URL in ${PRODUCTION_ENV} is not a public domain. Run ${command("dstack production --domain example.com --force")}.`);
928
1676
  let repository = options.repository;
929
- if (!repository) {
930
- const remote = gitRemote(project.directory);
931
- if (!remote) fail("This project has no git remote origin. Push it to GitHub first, or pass --repository.");
932
- repository = remote;
933
- }
934
- try {
935
- repository = httpsRepository(repository);
936
- } catch (error) {
937
- fail(error.message);
1677
+ if (provider.source === "remote") {
1678
+ if (!repository) {
1679
+ const remote = gitRemote(project.directory);
1680
+ if (!remote) fail("This project has no git remote origin. Push it to GitHub first, or pass --repository.");
1681
+ repository = remote;
1682
+ }
1683
+ try {
1684
+ repository = httpsRepository(repository);
1685
+ } catch (error) {
1686
+ fail(error.message);
1687
+ }
938
1688
  }
939
1689
  const branch = options.branch ?? gitBranch(project.directory) ?? "main";
940
1690
  const client = await connect(provider, instance, interactive);
@@ -957,12 +1707,12 @@ async function deploySetup(options) {
957
1707
  };
958
1708
  writeLink(project.directory, link);
959
1709
  p.log.success(`Wrote ${pc.bold(LINK_FILE)}. It holds no secrets; commit it so others deploy to the same place.`);
960
- p.note([`Point the DNS of ${pc.bold(domain)} at the ${provider.name} server.`, `Change variables in ${provider.name}, or in ${PRODUCTION_ENV} and run ${command("dstack deploy env push")}.`].join("\n"), "Next");
1710
+ p.note([`Point the DNS of ${pc.bold(domain)} at the ${provider.auth.kind === "ssh" ? "" : `${provider.name} `}server.`, provider.auth.kind === "ssh" ? `To change variables, edit ${PRODUCTION_ENV} and run ${command("dstack deploy env push")}.` : `Change variables in ${provider.name}, or in ${PRODUCTION_ENV} and run ${command("dstack deploy env push")}.`].join("\n"), "Next");
961
1711
  if (options.deploy ?? (interactive ? unwrap(await p.confirm({
962
1712
  message: "Deploy now?",
963
1713
  initialValue: true
964
1714
  })) : false)) {
965
- await startDeploy(project.directory, link, client, {
1715
+ await startDeploy(project.directory, provider, link, client, {
966
1716
  force: false,
967
1717
  wait: true
968
1718
  });
@@ -1011,16 +1761,27 @@ async function follow(link, client, id) {
1011
1761
  else spinner.error(summary);
1012
1762
  return deployment;
1013
1763
  }
1014
- async function startDeploy(directory, link, client, options) {
1764
+ /** Why the deploy would not include the local work, or undefined. */
1765
+ async function notDeployedReason(directory, provider, link, client) {
1766
+ if (provider.source === "local") return uncommittedReason(directory);
1767
+ const status = await attempt(client.status(link));
1768
+ return status.branch ? unpushedReason(directory, status.branch) : void 0;
1769
+ }
1770
+ async function startDeploy(directory, provider, link, client, options) {
1015
1771
  if (options.pushCheck !== false) {
1016
- const status = await attempt(client.status(link));
1017
- const reason = status.branch ? unpushedReason(directory, status.branch) : void 0;
1772
+ const reason = await notDeployedReason(directory, provider, link, client);
1018
1773
  if (reason) fail(`${reason} Or pass --no-push-check.`);
1019
1774
  }
1020
- const id = await attempt(client.deploy(link, { force: options.force }));
1021
- if (!options.wait) {
1775
+ await finishDeploy(link, client, await attempt(client.deploy(link, {
1776
+ force: options.force,
1777
+ directory
1778
+ })), options.wait);
1779
+ }
1780
+ /** Follows a started deploy, or reports where it runs when not waiting. */
1781
+ async function finishDeploy(link, client, id, wait) {
1782
+ if (!wait) {
1022
1783
  const deployment = await attempt(client.deployment(link, id));
1023
- p.outro(`Started deploy ${pc.bold(id)}: ${url(deployment.url ?? link.url)}`);
1784
+ p.outro(`Started deploy ${pc.bold(id)}${deployment.url ? `: ${url(deployment.url)}` : ""}. Follow it with ${command("dstack deploy status --watch")}`);
1024
1785
  return;
1025
1786
  }
1026
1787
  p.log.message(pc.dim("Ctrl+C stops following; the deploy goes on."));
@@ -1039,7 +1800,7 @@ async function deploy(options) {
1039
1800
  const provider = providerOrFail(link.provider);
1040
1801
  intro(`Deploy to ${provider.name}`);
1041
1802
  const client = await connect(provider, link.url, isInteractive(options.yes));
1042
- await startDeploy(project.directory, link, client, {
1803
+ await startDeploy(project.directory, provider, link, client, {
1043
1804
  force: Boolean(options.force),
1044
1805
  wait: options.wait,
1045
1806
  pushCheck: options.pushCheck
@@ -1110,6 +1871,17 @@ async function deployLogs(id, options) {
1110
1871
  process.stderr.write(pc.dim(`Deploy ${deployment.id}: ${deployment.status}\n`));
1111
1872
  if (deployment.status === "failed") process.exitCode = 1;
1112
1873
  }
1874
+ /** `dstack deploy rollback [deployment]`: run an earlier release again. */
1875
+ async function deployRollback(id, options) {
1876
+ const link = linkOrFail(projectOrFail(options).directory);
1877
+ const provider = providerOrFail(link.provider);
1878
+ intro(`Roll back on ${provider.name}`);
1879
+ const client = await connect(provider, link.url, isInteractive(options.yes));
1880
+ if (!client.rollback) fail(`${provider.name} keeps no releases to roll back to. Deploy an earlier commit, or roll back in ${provider.name}.`);
1881
+ const rollbackId = await attempt(client.rollback(link, id));
1882
+ p.log.info(pc.dim("The database is not rolled back; migrations of the newer release stay applied."));
1883
+ await finishDeploy(link, client, rollbackId, options.wait);
1884
+ }
1113
1885
  /** `dstack deploy env push`: upload .env.production to the platform again. */
1114
1886
  async function deployEnvPush(options) {
1115
1887
  const project = projectOrFail(options);
@@ -1128,18 +1900,19 @@ async function loginTarget(options, interactive) {
1128
1900
  link = readLink(readProject(options.dir ?? ".").directory);
1129
1901
  } catch {}
1130
1902
  const provider = providerOrFail(options.provider ?? link?.provider ?? DEFAULT_PROVIDER.id);
1903
+ tokenAuth(provider);
1131
1904
  let instance = options.url ?? link?.url;
1132
1905
  if (!instance) {
1133
1906
  if (!interactive) fail(`Pass --url, the address of your ${provider.name}.`);
1134
1907
  instance = unwrap(await p.text({
1135
- message: `${provider.name} URL`,
1136
- placeholder: "https://coolify.example.com"
1908
+ message: provider.target.label,
1909
+ placeholder: provider.target.placeholder
1137
1910
  }));
1138
1911
  }
1139
1912
  try {
1140
1913
  return {
1141
1914
  provider,
1142
- instance: normalizeUrl(instance)
1915
+ instance: provider.target.normalize(instance)
1143
1916
  };
1144
1917
  } catch (error) {
1145
1918
  fail(error.message);
@@ -1150,9 +1923,10 @@ async function deployLogin(options) {
1150
1923
  const interactive = isInteractive(options.yes);
1151
1924
  intro("Deploy login");
1152
1925
  const { provider, instance } = await loginTarget(options, interactive);
1153
- let token = options.token ?? (options.yes ? process.env[provider.tokenEnv] : void 0);
1926
+ const auth = tokenAuth(provider);
1927
+ let token = options.token ?? (options.yes ? process.env[auth.env] : void 0);
1154
1928
  if (!token) {
1155
- if (!interactive) fail(`Pass --token or set ${provider.tokenEnv}.`);
1929
+ if (!interactive) fail(`Pass --token or set ${auth.env}.`);
1156
1930
  token = await askToken(provider, instance);
1157
1931
  }
1158
1932
  await login(provider, instance, token);
@@ -1263,7 +2037,7 @@ function updateCommand(location = import.meta.url, env = process.env) {
1263
2037
  if (path.includes("/pnpm/global/")) return `pnpm add -g ${PACKAGE_NAME}@latest`;
1264
2038
  if (path.includes("/.bun/install/global/")) return `bun add -g ${PACKAGE_NAME}@latest`;
1265
2039
  if (env.npm_config_user_agent?.startsWith("pnpm")) return `pnpm add -g ${PACKAGE_NAME}@latest`;
1266
- return `npm install -g ${PACKAGE_NAME}@latest`;
2040
+ return `npm i -g ${PACKAGE_NAME}@latest`;
1267
2041
  }
1268
2042
  // src/global-install.ts
1269
2043
  /** The name of the installed command. */
@@ -1309,8 +2083,8 @@ function installCommand(runner = "npm") {
1309
2083
  ];
1310
2084
  return [
1311
2085
  "npm",
1312
- "install",
1313
- "--global",
2086
+ "i",
2087
+ "-g",
1314
2088
  `${PACKAGE_NAME}@latest`
1315
2089
  ];
1316
2090
  }
@@ -1572,7 +2346,7 @@ async function init(directoryArgument, options) {
1572
2346
  result = scaffold({
1573
2347
  directory: target,
1574
2348
  name,
1575
- frontend: "astro",
2349
+ frontend: stack.frontend,
1576
2350
  demoContent,
1577
2351
  databaseUrl,
1578
2352
  localS3: stack.storage === "s3",
@@ -1986,7 +2760,7 @@ function checkSteps(project, tests) {
1986
2760
  devOnly: true
1987
2761
  },
1988
2762
  {
1989
- name: "Astro check",
2763
+ name: "Frontend check",
1990
2764
  service: "frontend",
1991
2765
  args: [
1992
2766
  "npm",
@@ -2227,9 +3001,9 @@ function createProgram() {
2227
3001
  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);
2228
3002
  program.command("types").description("Generate the frontend's API types from the backend's schema").addOption(dirOption()).action(types);
2229
3003
  const providerOption = () => new Option("--provider <provider>", `The platform (default: ${DEFAULT_PROVIDER.id})`).choices(PROVIDERS.map((provider) => provider.id));
2230
- const deployCommand = program.command("deploy").description("Deploy to a platform such as Coolify, and show the status");
2231
- 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 the last commit is not pushed").option("-y, --yes", "Don't ask; fail when no API key is saved").action(deploy);
2232
- 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").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);
3004
+ const deployCommand = program.command("deploy").description("Deploy to Coolify, Dokploy or a server over SSH, and show the status");
3005
+ 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);
3006
+ 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);
2233
3007
  const seen = /* @__PURE__ */ new Set();
2234
3008
  for (const flag of PROVIDERS.flatMap((provider) => provider.setupFlags)) {
2235
3009
  if (seen.has(flag.name)) continue;
@@ -2239,6 +3013,7 @@ function createProgram() {
2239
3013
  }
2240
3014
  deployCommand.command("status").description("Show the state of the site and the last deploys").addOption(dirOption()).option("-n, --limit <count>", "How many deploys to list", "5").option("--watch", "Follow a running deploy until it ends").action(deployStatus);
2241
3015
  deployCommand.command("logs").description("Print the build log of a deploy").argument("[deployment]", "A deploy id from `dstack deploy status` (default: the latest)").addOption(dirOption()).action(deployLogs);
3016
+ deployCommand.command("rollback").description("Run an earlier release again (servers over SSH)").argument("[deployment]", "A deploy id from `dstack deploy status` (default: the one before the running one)").addOption(dirOption()).option("--no-wait", "Don't follow the rollback; return once it started").option("-y, --yes", "Don't ask").action(deployRollback);
2242
3017
  deployCommand.command("env").description("Manage the platform's environment variables").command("push").description("Replace the platform's variables with .env.production").addOption(dirOption()).option("-y, --yes", "Don't ask for confirmation").action(deployEnvPush);
2243
3018
  deployCommand.command("login").description("Check and save an API key in the system keychain").addOption(dirOption()).addOption(providerOption()).option("--url <url>", "The platform's address (default: the one in .dstack/deploy.json)").option("--token <token>", "The API key (default: ask, or the provider's environment variable with --yes)").option("-y, --yes", "Don't ask").action(deployLogin);
2244
3019
  deployCommand.command("logout").description("Remove a saved API key").addOption(dirOption()).addOption(providerOption()).option("--url <url>", "The platform's address (default: the one in .dstack/deploy.json)").action(deployLogout);