@studiometa/forge-cli 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { C as isOutputFormat, _ as setToken, a as runCommand, c as OutputFormatter, d as createConfigStore$1, f as deleteToken, g as setOrganizationSlug, h as getToken$1, l as colors, m as getOrganizationSlug, n as exitWithError, o as createContext, r as exitWithValidationError, t as ExitCode, u as setColorEnabled, v as ApiError, x as ValidationError, y as CliError } from "./error-handler-s-BOJ1-h.js";
3
3
  import { STORAGE_PROVIDER_TYPES } from "@studiometa/forge-api";
4
- import { PHP_CONFIG_TYPES, RESTARTABLE_SERVICES, activateCertificate, createAuditLogger, createBackupConfig, createCommand, createDaemon, createDatabase, createDatabaseUser, createDomain, createMonitor, createNginxTemplate, createRedirectRule, createScheduledJob, createSecurityRule, createServer, createSite, createSshKey, createStorageProvider, deleteBackupConfig, deleteDaemon, deleteDatabaseUser, deleteMonitor, deleteNginxTemplate, deleteRedirectRule, deleteScheduledJob, deleteSecurityRule, deleteSshKey, deleteStorageProvider, deploySiteAndWait, disablePhpOpcache, enablePhpOpcache, getBackupConfig, getCertificate, getCommand, getDaemon, getDaemonLog, getDatabase, getDatabaseUser, getDeploymentLog, getDeploymentScript, getEnv, getFirewallRule, getMonitor, getNginxConfig, getNginxTemplate, getPhpConfig, getPhpMaxExecutionTime, getPhpMaxUploadSize, getPhpOpcache, getRecipe, getRedirectRule, getScheduledJob, getSecurityRule, getServer, getSite, getSshKey, getStorageProvider, getUser, listBackupConfigs, listCommands, listDaemons, listDatabaseUsers, listDatabases, listDeployments, listFirewallRules, listMonitors, listNginxTemplates, listPhpVersions, listRecipes, listRedirectRules, listScheduledJobs, listSecurityRules, listServers, listServices, listSites, listSshKeys, listStorageProviders, matchByName, rebootServer, restartDaemon, restartService, runRecipe, updateBackupConfig, updateDaemon, updateDatabaseUser, updateDeploymentScript, updateEnv, updateNginxConfig, updateNginxTemplate, updatePhpConfig, updatePhpMaxExecutionTime, updatePhpMaxUploadSize, updateRecipe, updateSecurityRule, updateSite } from "@studiometa/forge-core";
4
+ import { PHP_CONFIG_TYPES, RESTARTABLE_SERVICES, activateCertificate, createAuditLogger, createBackupConfig, createCommand, createDaemon, createDatabase, createDatabaseUser, createDomain, createMonitor, createNginxTemplate, createRedirectRule, createScheduledJob, createSecurityRule, createServer, createSite, createSshKey, createStorageProvider, deleteBackupConfig, deleteDaemon, deleteDatabaseUser, deleteMonitor, deleteNginxTemplate, deleteRedirectRule, deleteScheduledJob, deleteSecurityRule, deleteSshKey, deleteStorageProvider, deploySiteAndWait, disablePhpOpcache, enablePhpOpcache, getBackupConfig, getCertificate, getCommand, getDaemon, getDaemonLog, getDatabase, getDatabaseUser, getDeploymentLog, getDeploymentScript, getEnv, getFirewallRule, getMonitor, getNginxConfig, getNginxTemplate, getPhpConfig, getPhpMaxExecutionTime, getPhpMaxUploadSize, getPhpOpcache, getRecipe, getRedirectRule, getScheduledJob, getSecurityRule, getServer, getSite, getSshKey, getStorageProvider, getUser, listBackupConfigs, listCommands, listDaemons, listDatabaseUsers, listDatabases, listDeployments, listFirewallRules, listLoadBalancingNodes, listMonitors, listNginxTemplates, listPhpVersions, listRecipes, listRedirectRules, listScheduledJobs, listSecurityRules, listServers, listServices, listSites, listSshKeys, listStorageProviders, matchByName, rebootServer, restartDaemon, restartService, runRecipe, updateBackupConfig, updateDaemon, updateDatabaseUser, updateDeploymentScript, updateEnv, updateLoadBalancingNodes, updateNginxConfig, updateNginxTemplate, updatePhpConfig, updatePhpMaxExecutionTime, updatePhpMaxUploadSize, updateRecipe, updateSecurityRule, updateSite } from "@studiometa/forge-core";
5
5
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
6
  import { homedir } from "node:os";
7
- import { join, posix } from "node:path";
7
+ import { dirname, join, posix, resolve } from "node:path";
8
8
  import { diff } from "node:util";
9
9
  import { createHash, randomBytes } from "node:crypto";
10
10
  import * as v from "valibot";
@@ -460,7 +460,7 @@ _forge_completions() {
460
460
 
461
461
  # Subcommands for each command
462
462
  local config_cmds="set get delete"
463
- local servers_cmds="list ls get create reboot"
463
+ local servers_cmds="list ls get public-key create reboot"
464
464
  local sites_cmds="list ls get apply"
465
465
  local deployments_cmds="list ls deploy logs log script update-script"
466
466
  local databases_cmds="list ls get"
@@ -672,6 +672,7 @@ _forge() {
672
672
  'list:List all servers'
673
673
  'ls:List all servers (alias)'
674
674
  'get:Get server details'
675
+ 'public-key:Print the public key of the forge user'
675
676
  'create:Create a server'
676
677
  'reboot:Reboot a server'
677
678
  )
@@ -1030,6 +1031,7 @@ complete -c forge -f -n "__fish_seen_subcommand_from config" -a "delete" -d "Del
1030
1031
  complete -c forge -f -n "__fish_seen_subcommand_from servers s" -a "list" -d "List all servers"
1031
1032
  complete -c forge -f -n "__fish_seen_subcommand_from servers s" -a "ls" -d "List all servers (alias)"
1032
1033
  complete -c forge -f -n "__fish_seen_subcommand_from servers s" -a "get" -d "Get server details"
1034
+ complete -c forge -f -n "__fish_seen_subcommand_from servers s" -a "public-key" -d "Print the public key of the forge user"
1033
1035
  complete -c forge -f -n "__fish_seen_subcommand_from servers s" -a "create" -d "Create a server"
1034
1036
  complete -c forge -f -n "__fish_seen_subcommand_from servers s" -a "reboot" -d "Reboot a server"
1035
1037
 
@@ -4290,6 +4292,23 @@ async function serversGet(args, ctx) {
4290
4292
  ctx.formatter.output(result.data);
4291
4293
  }, ctx.formatter);
4292
4294
  }
4295
+ /**
4296
+ * Print the public key of the `forge` user of a server (`~/.ssh/id_rsa.pub`), the key that
4297
+ * Forge uses to clone repositories: add it to the git host before you create a site.
4298
+ */
4299
+ async function serversPublicKey(args, ctx) {
4300
+ const [server] = args;
4301
+ if (!server) exitWithValidationError("server_id", "forge servers public-key <server_id>", ctx.formatter);
4302
+ await runCommand(async () => {
4303
+ const token = ctx.getToken();
4304
+ const execCtx = ctx.createExecutorContext(token);
4305
+ const result = await getServer({ server_id: await resolveServerId(server, execCtx) }, execCtx);
4306
+ const key = result.data.local_public_key?.trim();
4307
+ if (!key) throw new ApiError(`Server ${result.data.name} has no public key yet`, 404, ["Wait until Forge provisions the server"]);
4308
+ if (ctx.formatter.isJson()) ctx.formatter.output({ public_key: key });
4309
+ else console.log(key);
4310
+ }, ctx.formatter);
4311
+ }
4293
4312
  var CREATE_USAGE$1 = "forge servers create --name <name> --ip-address <ip> [options]";
4294
4313
  /**
4295
4314
  * Create a server. Defaults to a Custom VPS app server on Ubuntu 26.04.
@@ -4363,6 +4382,7 @@ var handleServersCommand = createCommandRouter({
4363
4382
  list: serversList,
4364
4383
  ls: serversList,
4365
4384
  get: [serversGet, "args"],
4385
+ "public-key": [serversPublicKey, "args"],
4366
4386
  create: serversCreate,
4367
4387
  reboot: [serversReboot, "args"]
4368
4388
  },
@@ -4402,6 +4422,27 @@ ${colors.bold("OPTIONS:")}
4402
4422
  ${colors.bold("EXAMPLES:")}
4403
4423
  forge servers get 123
4404
4424
  forge servers get 123 --format json
4425
+ `);
4426
+ else if (subcommand === "public-key") console.log(`
4427
+ ${colors.bold("forge servers public-key")} - Print the public key of the forge user
4428
+
4429
+ ${colors.bold("USAGE:")}
4430
+ forge servers public-key <server_id> [options]
4431
+
4432
+ ${colors.bold("ARGUMENTS:")}
4433
+ <server_id> Server ID or name (required)
4434
+
4435
+ ${colors.bold("OPTIONS:")}
4436
+ -f, --format <fmt> Output format: json ({ "public_key": "..." }), human (the key only)
4437
+
4438
+ Forge clones repositories as the forge user, with this key (~/.ssh/id_rsa.pub).
4439
+ Add it to the git host (e.g. as a deploy key) before you create a site with a
4440
+ repository. It is not the key of GET /servers/{id}/key: that key lets Forge
4441
+ connect to the server.
4442
+
4443
+ ${colors.bold("EXAMPLES:")}
4444
+ forge servers public-key 123
4445
+ forge servers public-key 123 > server.pub
4405
4446
  `);
4406
4447
  else if (subcommand === "create") console.log(`
4407
4448
  ${colors.bold("forge servers create")} - Create a server
@@ -4456,6 +4497,7 @@ ${colors.bold("ALIASES:")}
4456
4497
  ${colors.bold("SUBCOMMANDS:")}
4457
4498
  list, ls List all servers
4458
4499
  get <id> Get server details
4500
+ public-key <id> Print the public key of the forge user
4459
4501
  create Create a server (requires --name, and --ip-address for custom)
4460
4502
  reboot <id> Reboot a server
4461
4503
 
@@ -4849,6 +4891,14 @@ function planAliases(file, site, changes) {
4849
4891
  name: alias
4850
4892
  });
4851
4893
  }
4894
+ /** The .env keys of a new database user, from `database.env` or the Laravel names. */
4895
+ function databaseEnvKeys(database) {
4896
+ return {
4897
+ name: database.env?.name ?? "DB_DATABASE",
4898
+ user: database.env?.user ?? "DB_USERNAME",
4899
+ password: database.env?.password ?? "DB_PASSWORD"
4900
+ };
4901
+ }
4852
4902
  /**
4853
4903
  * Plan the database and its user. Returns the names of the .env keys that get generated values.
4854
4904
  */
@@ -4867,10 +4917,11 @@ function planDatabase(file, state, changes, warnings) {
4867
4917
  name: user,
4868
4918
  database: name
4869
4919
  });
4920
+ const keys = databaseEnvKeys(file.database);
4870
4921
  return [
4871
- "DB_DATABASE",
4872
- "DB_USERNAME",
4873
- "DB_PASSWORD"
4922
+ keys.name,
4923
+ keys.user,
4924
+ keys.password
4874
4925
  ];
4875
4926
  }
4876
4927
  if (!databaseExists) warnings.push(`Database user ${user} exists: apply does not give it access to the new database ${name}.`);
@@ -4884,10 +4935,29 @@ function planEnv(file, state, generated, changes, warnings) {
4884
4935
  if (!remote.has(key)) keys.push(key);
4885
4936
  else if (remote.get(key) !== String(value)) warnings.push(`.env key ${key} has another value on Forge: apply does not overwrite it.`);
4886
4937
  }
4887
- if (keys.length > 0 || generated.length > 0) changes.push({
4888
- action: "add-env",
4889
- keys,
4890
- generated
4938
+ if (keys.length > 0 || generated.length > 0) {
4939
+ const password = file.database && generated.length > 0 ? databaseEnvKeys(file.database).password : void 0;
4940
+ changes.push({
4941
+ action: "add-env",
4942
+ keys,
4943
+ generated,
4944
+ ...password ? { password } : {}
4945
+ });
4946
+ }
4947
+ }
4948
+ function planNginx(file, state, changes) {
4949
+ if (file.nginxTemplate === void 0) return;
4950
+ if (!state.nginx) {
4951
+ changes.push({
4952
+ action: "update-nginx",
4953
+ diff: null
4954
+ });
4955
+ return;
4956
+ }
4957
+ const diff = unifiedDiff(state.nginx.remote, state.nginx.rendered);
4958
+ if (diff) changes.push({
4959
+ action: "update-nginx",
4960
+ diff
4891
4961
  });
4892
4962
  }
4893
4963
  function describeOwnership(ownership) {
@@ -4999,6 +5069,29 @@ function planSchedule(file, state, changes, warnings) {
4999
5069
  else warnings.push(`Kept scheduled job ${job.id} (${job.command}): not declared, but ${describeOwnership(ownership)}.`);
5000
5070
  }
5001
5071
  /**
5072
+ * Plan the nodes of the site on the load balancer: one node, the server of the site file, with
5073
+ * the default weight. Any other node is replaced.
5074
+ */
5075
+ function planLoadBalancer(file, state, changes) {
5076
+ const balancer = state.loadBalancer;
5077
+ if (!file.loadBalancer || !balancer?.site) return;
5078
+ const to = {
5079
+ server_id: balancer.target,
5080
+ port: file.loadBalancer.port
5081
+ };
5082
+ const [node, ...others] = balancer.nodes;
5083
+ if (node !== void 0 && others.length === 0 && node.server_id === to.server_id && node.port === to.port && node.weight === 1 && !node.backup && !node.down) return;
5084
+ changes.push({
5085
+ action: "update-load-balancer",
5086
+ site: balancer.site.name,
5087
+ from: balancer.nodes.map(({ server_id, port }) => ({
5088
+ server_id,
5089
+ port
5090
+ })),
5091
+ to
5092
+ });
5093
+ }
5094
+ /**
5002
5095
  * Compare the site file with the state of Forge. Keys that the file does not have are not
5003
5096
  * compared. The plan creates and updates what differs, and deletes only the workers and
5004
5097
  * scheduled jobs that surely belong to the site and that the file does not declare.
@@ -5010,8 +5103,10 @@ function buildPlan(file, state) {
5010
5103
  planAliases(file, state.site, changes);
5011
5104
  const generated = planDatabase(file, state, changes, warnings);
5012
5105
  if (file.env || generated.length > 0) planEnv(file, state, generated, changes, warnings);
5106
+ planNginx(file, state, changes);
5013
5107
  planWorkers(file, state, changes, warnings);
5014
5108
  planSchedule(file, state, changes, warnings);
5109
+ planLoadBalancer(file, state, changes);
5015
5110
  return {
5016
5111
  changes,
5017
5112
  warnings
@@ -5033,14 +5128,96 @@ function describeChange(change) {
5033
5128
  case "add-alias": return `+ add alias ${change.name}`;
5034
5129
  case "create-database": return `+ create database ${change.name}`;
5035
5130
  case "create-database-user": return `+ create database user ${change.name} for ${change.database} (password: ${GENERATED})`;
5036
- case "add-env": return `+ add .env keys: ${[...change.keys, ...change.generated.map((key) => key === "DB_PASSWORD" ? `${key}=${GENERATED}` : key)].join(", ")}`;
5131
+ case "add-env": return `+ add .env keys: ${[...change.keys, ...change.generated.map((key) => key === change.password ? `${key}=${GENERATED}` : key)].join(", ")}`;
5132
+ case "update-nginx": return change.diff === null ? "~ write Nginx configuration (rendered once the site exists)" : `~ update Nginx configuration:\n${change.diff}`;
5037
5133
  case "create-worker": return `+ create worker: ${change.command} (directory: ${change.directory}, processes: ${change.processes})`;
5038
5134
  case "delete-worker": return `- delete worker ${change.id}: ${change.command} (directory: ${quoted(change.directory)}, processes: ${change.processes})`;
5039
5135
  case "create-job": return `+ create scheduled job: ${change.command} (${change.frequency}${change.cron ? ` ${change.cron}` : ""})`;
5136
+ case "update-load-balancer": {
5137
+ const node = ({ server_id, port }) => `server ${server_id}:${port}`;
5138
+ const from = change.from.length > 0 ? change.from.map(node).join(", ") : "(none)";
5139
+ return `~ update load balancer site ${change.site}: ${from} → ${node(change.to)}`;
5140
+ }
5040
5141
  case "delete-job": return `- delete scheduled job ${change.id}: ${change.command} (${change.frequency.toLowerCase()} ${change.cron})`;
5041
5142
  }
5042
5143
  }
5043
5144
  //#endregion
5145
+ //#region src/commands/sites/apply/nginx.ts
5146
+ /** The variables of Forge Nginx templates. Forge accepts spaces inside the braces. */
5147
+ var NGINX_VARIABLES = [
5148
+ "DIRECTORY",
5149
+ "DOMAINS",
5150
+ "PATH",
5151
+ "PORT",
5152
+ "PORT_V6",
5153
+ "PROXY_PASS",
5154
+ "ROOT_PATH",
5155
+ "SERVER_PUBLIC_IP",
5156
+ "SERVER_PRIVATE_IP",
5157
+ "SITE",
5158
+ "SITE_ID",
5159
+ "USER"
5160
+ ];
5161
+ /** The variables that need the server, not only the site. */
5162
+ var SERVER_VARIABLES = /* @__PURE__ */ new Set(["SERVER_PUBLIC_IP", "SERVER_PRIVATE_IP"]);
5163
+ var VARIABLE = /\{\{\s*([^{}]*?)\s*\}\}/g;
5164
+ /** The names of the variables in a template, each once, in order. */
5165
+ function templateVariables(template) {
5166
+ return [...new Set([...template.matchAll(VARIABLE)].map((match) => match[1]))];
5167
+ }
5168
+ /**
5169
+ * Exit with a validation error when the template has a variable that Forge does not define.
5170
+ */
5171
+ function assertNginxTemplate(template, source) {
5172
+ const known = new Set(NGINX_VARIABLES);
5173
+ const unknown = templateVariables(template).filter((name) => !known.has(name));
5174
+ if (unknown.length > 0) throw new ValidationError(`Nginx file ${source} has unknown variables: ${unknown.map((name) => `{{${name}}}`).join(", ")}`, "nginx", [`Supported variables: ${NGINX_VARIABLES.map((name) => `{{${name}}}`).join(", ")}`]);
5175
+ }
5176
+ /** True when rendering the template needs the attributes of the server. */
5177
+ function needsServer(template) {
5178
+ return templateVariables(template).some((name) => SERVER_VARIABLES.has(name));
5179
+ }
5180
+ /** `PHP 8.3`, `php83` and `php8.3` all give `8.3`. */
5181
+ function phpSocketVersion(version) {
5182
+ const digits = version.replaceAll(/\D/g, "");
5183
+ if (digits.length < 2) throw new ValidationError(`Cannot read the PHP version ${version} of the site`, "nginx");
5184
+ return `${digits[0]}.${digits.slice(1)}`;
5185
+ }
5186
+ /**
5187
+ * The value of each variable for a site, as Forge renders its templates.
5188
+ * A server is needed only for the `SERVER_*` variables.
5189
+ */
5190
+ function nginxValues(site, server = null) {
5191
+ const root = siteWorkDirectory(site);
5192
+ const path = isPathInside(site.web_directory, root) ? normalizePath(site.web_directory) : normalizePath(`${root}/${site.web_directory}`);
5193
+ const directory = path === root ? "/" : path.slice(root.length);
5194
+ const defaultServer = site.name === "default" ? " default_server" : "";
5195
+ const isolated = site.isolated ? `-${site.user}` : "";
5196
+ return {
5197
+ DIRECTORY: directory,
5198
+ DOMAINS: [site.name, ...site.aliases ?? []].join(" "),
5199
+ PATH: path,
5200
+ PORT: `80${defaultServer}`,
5201
+ PORT_V6: `[::]:80${defaultServer}`,
5202
+ get PROXY_PASS() {
5203
+ return `unix:/var/run/php/php${phpSocketVersion(site.php_version)}-fpm${isolated}.sock`;
5204
+ },
5205
+ ROOT_PATH: root,
5206
+ SERVER_PUBLIC_IP: server?.ip_address ?? "",
5207
+ SERVER_PRIVATE_IP: server?.private_ip_address ?? "",
5208
+ SITE: site.name,
5209
+ SITE_ID: String(site.id),
5210
+ USER: site.user
5211
+ };
5212
+ }
5213
+ /**
5214
+ * Replace each variable of the template with its value for the site.
5215
+ */
5216
+ function renderNginx(template, site, server = null) {
5217
+ const values = nginxValues(site, server);
5218
+ return template.replaceAll(VARIABLE, (match, name) => name in values ? values[name] : match);
5219
+ }
5220
+ //#endregion
5044
5221
  //#region src/commands/sites/apply/apply.ts
5045
5222
  /**
5046
5223
  * Read the state of Forge for a site file, and run a plan against Forge.
@@ -5065,6 +5242,24 @@ function findSite(sites, domain) {
5065
5242
  return sites.find((site) => site.name.toLowerCase() === domain.toLowerCase()) ?? null;
5066
5243
  }
5067
5244
  /**
5245
+ * The site of the load balancer named `domain` or one of the aliases, compared without case.
5246
+ * Its name can be any of them, e.g. the domain without `www`. Two matches are an error: a guess
5247
+ * could send the traffic of another site.
5248
+ */
5249
+ function findLoadBalancerSite(sites, names) {
5250
+ const wanted = new Set(names.map((name) => name.toLowerCase()));
5251
+ const matches = sites.filter((site) => wanted.has(site.name.toLowerCase()));
5252
+ if (matches.length > 1) throw new ValidationError(`Several sites of the load balancer match the site file: ${matches.map((site) => site.name).join(", ")}`, "loadBalancer", ["Keep one site for the domain and its aliases on the load balancer"]);
5253
+ return matches[0] ?? null;
5254
+ }
5255
+ /**
5256
+ * Exit with a validation error when the load balancer has no site for the site file: the Forge
5257
+ * API cannot create a site on a load balancer.
5258
+ */
5259
+ function assertLoadBalancerSite(file, state) {
5260
+ if (file.loadBalancer && !state.loadBalancer?.site) throw new ValidationError(`The load balancer ${file.loadBalancer.server} has no site for ${file.domain}`, "loadBalancer", [`In Forge, create the site ${file.domain} on the load balancer (New site), then run apply again`, "Check the domain, the aliases and the server of the load balancer"]);
5261
+ }
5262
+ /**
5068
5263
  * Read from Forge what the site file manages, and nothing else.
5069
5264
  */
5070
5265
  async function readRemoteState(file, serverId, ctx) {
@@ -5090,6 +5285,7 @@ async function readRemoteState(file, serverId, ctx) {
5090
5285
  site_id: siteId
5091
5286
  }, ctx)).data.map((d) => d.id) : []);
5092
5287
  }
5288
+ if (file.nginxTemplate !== void 0) state.nginx = site ? await readNginx(file.nginxTemplate, serverId, site, ctx) : null;
5093
5289
  if (file.schedule) {
5094
5290
  const siteJobs = siteId ? (await listScheduledJobs({
5095
5291
  server_id: serverId,
@@ -5099,9 +5295,35 @@ async function readRemoteState(file, serverId, ctx) {
5099
5295
  state.jobs = [...siteJobs, ...serverJobs];
5100
5296
  state.siteJobIds = new Set(siteJobs.map((job) => job.id));
5101
5297
  }
5298
+ if (file.loadBalancer) {
5299
+ const balancerId = await resolveApplyServer(file.loadBalancer.server, ctx);
5300
+ const balancerSites = (await listSites({ server_id: balancerId }, ctx)).data;
5301
+ const balancerSite = findLoadBalancerSite(balancerSites, [file.domain, ...file.aliases ?? []]);
5302
+ state.loadBalancer = {
5303
+ serverId: balancerId,
5304
+ site: balancerSite,
5305
+ nodes: balancerSite ? (await listLoadBalancingNodes({
5306
+ server_id: balancerId,
5307
+ site_id: String(balancerSite.id)
5308
+ }, ctx)).data : [],
5309
+ target: Number(serverId)
5310
+ };
5311
+ }
5102
5312
  return state;
5103
5313
  }
5104
5314
  /**
5315
+ * Read the Nginx configuration of a site on Forge and render the Nginx file for the site.
5316
+ */
5317
+ async function readNginx(template, serverId, site, ctx) {
5318
+ return {
5319
+ remote: (await getNginxConfig({
5320
+ server_id: serverId,
5321
+ site_id: String(site.id)
5322
+ }, ctx)).data,
5323
+ rendered: renderNginx(template, site, needsServer(template) ? (await getServer({ server_id: serverId }, ctx)).data : null)
5324
+ };
5325
+ }
5326
+ /**
5105
5327
  * Exit with a validation error when the site does not exist and the site file cannot create it.
5106
5328
  */
5107
5329
  function assertCanCreateSite(file, state) {
@@ -5138,10 +5360,12 @@ function setEnvValues(content, values) {
5138
5360
  for (const [name, value] of pending) result.push(`${name}=${formatEnvValue(value)}`);
5139
5361
  return `${result.join("\n")}\n`;
5140
5362
  }
5363
+ /** Forge clones the repository with the key of the server: the git host must accept it. */
5364
+ var REPOSITORY_HINT = (serverId) => `Check that the git host accepts the key of the server: forge servers public-key ${serverId}`;
5141
5365
  var defaultSleep = (ms) => new Promise((resolve) => {
5142
5366
  setTimeout(resolve, ms);
5143
5367
  });
5144
- async function waitForSite(serverId, siteId, ctx, options) {
5368
+ async function waitForSite(file, serverId, siteId, ctx, options) {
5145
5369
  const sleep = options.sleep ?? defaultSleep;
5146
5370
  const interval = options.pollIntervalMs ?? 5e3;
5147
5371
  const timeout = options.timeoutMs ?? 6e5;
@@ -5151,7 +5375,7 @@ async function waitForSite(serverId, siteId, ctx, options) {
5151
5375
  site_id: siteId
5152
5376
  }, ctx)).data;
5153
5377
  if (site.status === "installed") return;
5154
- if (waited >= timeout) throw new ApplyError(`Site ${site.name} is still ${site.status} on Forge`, ["Run forge sites apply again once the site is installed"]);
5378
+ if (waited >= timeout) throw new ApplyError(`Site ${site.name} is still ${site.status} on Forge`, ["Run forge sites apply again once the site is installed", ...file.repository ? [REPOSITORY_HINT(serverId)] : []]);
5155
5379
  await sleep(interval);
5156
5380
  }
5157
5381
  }
@@ -5182,9 +5406,10 @@ async function applyDatabase(file, plan, state, serverId, ctx, options) {
5182
5406
  ...databaseId === void 0 ? {} : { database_ids: [databaseId] }
5183
5407
  }, ctx);
5184
5408
  ids.database_user_id = String(user.data.id);
5185
- env.set("DB_DATABASE", change.database);
5186
- env.set("DB_USERNAME", change.name);
5187
- env.set("DB_PASSWORD", password);
5409
+ const keys = databaseEnvKeys(file.database);
5410
+ env.set(keys.name, change.database);
5411
+ env.set(keys.user, change.name);
5412
+ env.set(keys.password, password);
5188
5413
  }
5189
5414
  if (databaseId !== void 0) ids.database_id = databaseId;
5190
5415
  return {
@@ -5193,6 +5418,29 @@ async function applyDatabase(file, plan, state, serverId, ctx, options) {
5193
5418
  };
5194
5419
  }
5195
5420
  /**
5421
+ * Write the rendered Nginx file when it differs from Forge. After a change of the site or its
5422
+ * aliases, read the site again: the web directory and the domains are template variables.
5423
+ */
5424
+ async function applyNginx(template, plan, serverId, site, ctx, options) {
5425
+ const siteChanged = plan.changes.some((change) => change.action === "update-site" || change.action === "add-alias");
5426
+ if (!plan.changes.some((change) => change.action === "update-nginx") && !siteChanged) return;
5427
+ const params = {
5428
+ server_id: serverId,
5429
+ site_id: String(site.id)
5430
+ };
5431
+ const { remote, rendered } = await readNginx(template, serverId, siteChanged ? (await getSite(params, ctx)).data : site, ctx);
5432
+ const diff = unifiedDiff(remote, rendered);
5433
+ if (!diff) return;
5434
+ options.onChange?.({
5435
+ action: "update-nginx",
5436
+ diff
5437
+ });
5438
+ await updateNginxConfig({
5439
+ ...params,
5440
+ content: rendered
5441
+ }, ctx);
5442
+ }
5443
+ /**
5196
5444
  * Run the changes of a plan for a site that exists.
5197
5445
  */
5198
5446
  async function applyChanges(file, plan, state, serverId, site, generatedEnv, ctx, options) {
@@ -5250,6 +5498,7 @@ async function applyChanges(file, plan, state, serverId, site, generatedEnv, ctx
5250
5498
  content: setEnvValues(content, env)
5251
5499
  }, ctx);
5252
5500
  }
5501
+ if (file.nginxTemplate !== void 0) await applyNginx(file.nginxTemplate, plan, serverId, site, ctx, options);
5253
5502
  for (const change of creations) {
5254
5503
  options.onChange?.(change);
5255
5504
  if (change.action === "create-worker") await createDaemon({
@@ -5269,6 +5518,20 @@ async function applyChanges(file, plan, state, serverId, site, generatedEnv, ctx
5269
5518
  ...change.cron ? { cron: change.cron } : {}
5270
5519
  }, ctx);
5271
5520
  }
5521
+ const balancer = state.loadBalancer;
5522
+ const balancing = plan.changes.find((change) => change.action === "update-load-balancer");
5523
+ if (balancing?.action === "update-load-balancer" && balancer?.site) {
5524
+ options.onChange?.(balancing);
5525
+ await updateLoadBalancingNodes({
5526
+ server_id: balancer.serverId,
5527
+ site_id: String(balancer.site.id),
5528
+ balancer_method: "round_robin",
5529
+ balancing: [{
5530
+ ...balancing.to,
5531
+ weight: 1
5532
+ }]
5533
+ }, ctx);
5534
+ }
5272
5535
  for (const change of deletions) {
5273
5536
  options.onChange?.(change);
5274
5537
  if (change.action === "delete-worker") await deleteDaemon({
@@ -5288,6 +5551,7 @@ async function applyChanges(file, plan, state, serverId, site, generatedEnv, ctx
5288
5551
  */
5289
5552
  async function applySiteFile(file, serverId, ctx, options = {}) {
5290
5553
  let state = await readRemoteState(file, serverId, ctx);
5554
+ assertLoadBalancerSite(file, state);
5291
5555
  const firstPlan = buildPlan(file, state);
5292
5556
  let plan = firstPlan;
5293
5557
  let generatedEnv = /* @__PURE__ */ new Map();
@@ -5309,7 +5573,7 @@ async function applySiteFile(file, serverId, ctx, options = {}) {
5309
5573
  ...settings.branch ? { branch: settings.branch } : {},
5310
5574
  ...database.ids
5311
5575
  }, ctx);
5312
- await waitForSite(serverId, String(created.data.id), ctx, options);
5576
+ await waitForSite(file, serverId, String(created.data.id), ctx, options);
5313
5577
  state = await readRemoteState(file, serverId, ctx);
5314
5578
  plan = buildPlan(file, state);
5315
5579
  }
@@ -5325,6 +5589,8 @@ async function applySiteFile(file, serverId, ctx, options = {}) {
5325
5589
  * A key that is absent is not managed; only `server` and `domain` are required.
5326
5590
  */
5327
5591
  var text = v.pipe(v.string(), v.trim(), v.nonEmpty());
5592
+ /** A server name (exact match) or ID. */
5593
+ var serverRef = v.union([text, v.pipe(v.number(), v.integer(), v.minValue(1))]);
5328
5594
  var SITE_TYPES = [
5329
5595
  "laravel",
5330
5596
  "symfony",
@@ -5364,9 +5630,25 @@ var ScheduledJobSchema = v.pipe(v.strictObject({
5364
5630
  frequency: v.picklist(SCHEDULE_FREQUENCIES),
5365
5631
  cron: v.optional(text)
5366
5632
  }), v.check((job) => job.frequency === "custom" === (job.cron !== void 0), "cron is required with the custom frequency, and only with it"));
5633
+ var envName = v.pipe(v.string(), v.regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "invalid .env variable name"));
5634
+ /** The .env keys that a new database user gets. Default: the Laravel names. */
5635
+ var DatabaseEnvSchema = v.pipe(v.strictObject({
5636
+ name: v.optional(envName),
5637
+ user: v.optional(envName),
5638
+ password: v.optional(envName)
5639
+ }), v.check((env) => (/* @__PURE__ */ new Set([
5640
+ env.name ?? "DB_DATABASE",
5641
+ env.user ?? "DB_USERNAME",
5642
+ env.password ?? "DB_PASSWORD"
5643
+ ])).size === 3, "the .env keys of the database must be different"));
5644
+ /** The load balancer that sends the traffic of the site to its server. */
5645
+ var LoadBalancerSchema = v.strictObject({
5646
+ server: serverRef,
5647
+ port: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(65535)), 80)
5648
+ });
5367
5649
  var SiteFileSchema = v.strictObject({
5368
5650
  $schema: v.optional(v.string()),
5369
- server: v.union([text, v.pipe(v.number(), v.integer(), v.minValue(1))]),
5651
+ server: serverRef,
5370
5652
  domain: text,
5371
5653
  aliases: v.optional(v.array(text)),
5372
5654
  type: v.optional(v.picklist(SITE_TYPES)),
@@ -5379,15 +5661,18 @@ var SiteFileSchema = v.strictObject({
5379
5661
  })),
5380
5662
  database: v.optional(v.strictObject({
5381
5663
  name: text,
5382
- user: text
5664
+ user: text,
5665
+ env: v.optional(DatabaseEnvSchema)
5383
5666
  })),
5384
- env: v.optional(v.record(v.pipe(v.string(), v.regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "invalid .env variable name")), v.union([
5667
+ env: v.optional(v.record(envName, v.union([
5385
5668
  v.string(),
5386
5669
  v.number(),
5387
5670
  v.boolean()
5388
5671
  ]))),
5672
+ nginx: v.optional(text),
5389
5673
  workers: v.optional(v.array(WorkerSchema)),
5390
- schedule: v.optional(v.array(ScheduledJobSchema))
5674
+ schedule: v.optional(v.array(ScheduledJobSchema)),
5675
+ loadBalancer: v.optional(LoadBalancerSchema)
5391
5676
  });
5392
5677
  /**
5393
5678
  * Validate parsed JSON as a site file. Throws a ValidationError that lists every issue.
@@ -5403,7 +5688,8 @@ function parseSiteFile(data, source = "site file") {
5403
5688
  return result.output;
5404
5689
  }
5405
5690
  /**
5406
- * Read and validate a site file from disk.
5691
+ * Read and validate a site file from disk, with its Nginx file: a relative `nginx` path is
5692
+ * read against the folder of the site file.
5407
5693
  */
5408
5694
  function readSiteFile(path) {
5409
5695
  let content;
@@ -5418,7 +5704,17 @@ function readSiteFile(path) {
5418
5704
  } catch (error) {
5419
5705
  throw new ValidationError(`Site file ${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, "file");
5420
5706
  }
5421
- return parseSiteFile(data, `site file ${path}`);
5707
+ const file = parseSiteFile(data, `site file ${path}`);
5708
+ if (file.nginx) {
5709
+ const nginxPath = resolve(dirname(path), file.nginx);
5710
+ try {
5711
+ file.nginxTemplate = readFileSync(nginxPath, "utf8");
5712
+ } catch (error) {
5713
+ throw new ValidationError(`Cannot read Nginx file ${nginxPath}: ${error instanceof Error ? error.message : String(error)}`, "nginx");
5714
+ }
5715
+ assertNginxTemplate(file.nginxTemplate, nginxPath);
5716
+ }
5717
+ return file;
5422
5718
  }
5423
5719
  //#endregion
5424
5720
  //#region src/commands/sites/apply/handler.ts
@@ -5439,6 +5735,7 @@ async function sitesApply(ctx, applyOptions = {}) {
5439
5735
  if (check) {
5440
5736
  const state = await readRemoteState(file, server_id, execCtx);
5441
5737
  assertCanCreateSite(file, state);
5738
+ assertLoadBalancerSite(file, state);
5442
5739
  const plan = buildPlan(file, state);
5443
5740
  const inSync = plan.changes.length === 0;
5444
5741
  if (ctx.formatter.isJson()) ctx.formatter.output({
@@ -5610,9 +5907,15 @@ ${colors.bold("SITE FILE:")}
5610
5907
  A key that is absent is not managed: apply does not read or change it.
5611
5908
  type, php, webDirectory, repository Create the site, or update php, web directory, branch
5612
5909
  aliases Add missing aliases, never remove one
5613
- database { name, user } Create when missing; a new user gets a random
5614
- password, written into the site .env only
5910
+ database { name, user, env } Create when missing; a new user gets a random
5911
+ password, written into the site .env only.
5912
+ env maps { name, user, password } to .env keys
5913
+ (default DB_DATABASE, DB_USERNAME, DB_PASSWORD)
5615
5914
  env Add missing keys, never overwrite a value
5915
+ nginx Nginx file, relative to the site file, with Forge
5916
+ template variables such as {{PATH}}, {{SITE_ID}},
5917
+ {{PROXY_PASS}}: rendered for the site, written
5918
+ when it differs (an unknown variable exits 3)
5616
5919
  workers, schedule Create missing ones, delete the ones of the site
5617
5920
  that the file does not declare ([] deletes all)
5618
5921
 
@@ -6493,7 +6796,7 @@ function isVersionRequest({ command, options }) {
6493
6796
  }
6494
6797
  //#endregion
6495
6798
  //#region src/cli.ts
6496
- var VERSION = "0.5.2";
6799
+ var VERSION = "0.5.4";
6497
6800
  function showHelp() {
6498
6801
  console.log(`
6499
6802
  ${colors.bold("forge")} v${VERSION}