@studiometa/forge-cli 0.5.2 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -121,6 +121,7 @@ forge config delete # Delete all stored config
121
121
  ```bash
122
122
  forge servers list # List all servers
123
123
  forge servers get <id> # Get server details
124
+ forge servers public-key <id> # Print the public key of the forge user, for the git host
124
125
  forge servers reboot <id> # Reboot a server
125
126
  forge servers create --name <name> --ip-address <ip> [--php-version php84 --database mariadb1011]
126
127
  # Create a server (Custom VPS by default)
@@ -155,6 +156,7 @@ forge sites apply --file site.json # Make the site match the file
155
156
  },
156
157
  "database": { "name": "example", "user": "example" },
157
158
  "env": { "APP_ENV": "production" },
159
+ "nginx": "nginx.conf",
158
160
  "workers": [{ "command": "php artisan queue:work database --queue=default", "processes": 4 }],
159
161
  "schedule": [{ "command": "php artisan schedule:run", "frequency": "minutely" }]
160
162
  }
@@ -165,11 +167,44 @@ forge sites apply --file site.json # Make the site match the file
165
167
  | `server`, `domain` | Find the server and the site by exact name (never a partial match, with or without `--exact`). `server` can also be an ID |
166
168
  | `type`, `php`, `webDirectory`, `repository` | Create the site when it is missing (`type` is then required) and wait until Forge installs it. On an existing site, update `php`, `webDirectory` and the branch |
167
169
  | `aliases` | Add the missing aliases. Never remove one |
168
- | `database` | Create the database and the user when they are missing. A new user gets a random password, written only into the site `.env` (`DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD`) |
170
+ | `database` | Create the database and the user when they are missing. A new user gets a random password, written only into the site `.env` (`DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD`, see below) |
169
171
  | `env` | Add the missing keys to the site `.env`. A key with another value on Forge is reported, never overwritten |
172
+ | `nginx` | Nginx file, relative to the site file, with Forge template variables. Rendered for the site and written when it differs from Forge (see below) |
170
173
  | `workers` | Forge background processes of the site. Create the missing ones, delete the ones of the site that the file does not declare. The directory defaults to the site root |
171
174
  | `schedule` | Forge scheduled jobs of the site (`minutely`, `hourly`, `nightly`, `weekly`, `monthly`, `reboot`, or `custom` with `cron`). A relative `artisan` becomes the absolute path of the site |
172
175
 
176
+ For an application that does not read the Laravel names, `database.env` maps the database name, the user and the password to other `.env` keys. Each key is optional and defaults to the Laravel name. Other values, such as the host, go in `env`:
177
+
178
+ ```json
179
+ {
180
+ "database": {
181
+ "name": "example",
182
+ "user": "example",
183
+ "env": { "name": "DB_NAME", "user": "DB_USER", "password": "DB_PASSWD" }
184
+ },
185
+ "env": { "DB_SERVER": "127.0.0.1" }
186
+ }
187
+ ```
188
+
189
+ `nginx` points to an Nginx file that uses the variables of [Forge Nginx templates](https://forge.laravel.com/docs/servers/nginx-templates). `apply` renders the file locally with the values of the site, so one file works on any server, without a template on the server:
190
+
191
+ | Variable | Value |
192
+ | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
193
+ | `{{SITE}}`, `{{SITE_ID}}`, `{{USER}}` | Site name (`www.example.com`), site ID (`456`), site user (`forge`) |
194
+ | `{{DOMAINS}}` | Site name and aliases, separated by spaces |
195
+ | `{{ROOT_PATH}}`, `{{PATH}}`, `{{DIRECTORY}}` | Site root, web directory (`/home/forge/www.example.com/public`) and its relative path (`/public`) |
196
+ | `{{PROXY_PASS}}` | PHP-FPM socket, e.g. `unix:/var/run/php/php8.3-fpm.sock` (`php8.3-fpm-<user>.sock` for an isolated site) |
197
+ | `{{PORT}}`, `{{PORT_V6}}` | `80` and `[::]:80` (with `default_server` for the site named `default`) |
198
+ | `{{SERVER_PUBLIC_IP}}`, `{{SERVER_PRIVATE_IP}}` | IP addresses of the server (empty when there is no private IP) |
199
+
200
+ Spaces inside the braces are allowed (`{{ SITE_ID }}`). An unknown variable is an error (exit `3`). On a new site, `apply` writes the file after Forge installs the site. On an existing site, the plan shows the diff from Forge to the rendered file (CRLF and the final newline do not count), and `apply` writes it. When `apply` also changes the site or its aliases, it reads the site again and renders the file with the new values. Forge returns the full `server` block on old servers and only its content on recent ones: write the file in the same form as `forge nginx get` prints it.
201
+
202
+ Forge clones the repository when it creates the site, with the key of the `forge` user of the server. Add this key to the git host (for example as a deploy key) before `apply` creates the site:
203
+
204
+ ```bash
205
+ forge servers public-key web-1 > server.pub # The key only; --format json gives { "public_key": "..." }
206
+ ```
207
+
173
208
  A worker or a scheduled job matches the file when its command, directory, processes (or frequency) and user are the same. Commands are compared after normalization: quotes (`'database'` and `database`), repeated spaces and a relative `artisan` (`php artisan` and `php /home/forge/www.example.com/artisan`) do not count as differences. Forge cannot update a worker: a changed worker is created again, then the old one is deleted.
174
209
 
175
210
  `workers` and `schedule` delete what the file does not declare, so `apply` deletes only what surely belongs to the site, because a server often hosts several sites:
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@ import { STORAGE_PROVIDER_TYPES } from "@studiometa/forge-api";
4
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";
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) {
@@ -5010,6 +5080,7 @@ function buildPlan(file, state) {
5010
5080
  planAliases(file, state.site, changes);
5011
5081
  const generated = planDatabase(file, state, changes, warnings);
5012
5082
  if (file.env || generated.length > 0) planEnv(file, state, generated, changes, warnings);
5083
+ planNginx(file, state, changes);
5013
5084
  planWorkers(file, state, changes, warnings);
5014
5085
  planSchedule(file, state, changes, warnings);
5015
5086
  return {
@@ -5033,7 +5104,8 @@ function describeChange(change) {
5033
5104
  case "add-alias": return `+ add alias ${change.name}`;
5034
5105
  case "create-database": return `+ create database ${change.name}`;
5035
5106
  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(", ")}`;
5107
+ case "add-env": return `+ add .env keys: ${[...change.keys, ...change.generated.map((key) => key === change.password ? `${key}=${GENERATED}` : key)].join(", ")}`;
5108
+ case "update-nginx": return change.diff === null ? "~ write Nginx configuration (rendered once the site exists)" : `~ update Nginx configuration:\n${change.diff}`;
5037
5109
  case "create-worker": return `+ create worker: ${change.command} (directory: ${change.directory}, processes: ${change.processes})`;
5038
5110
  case "delete-worker": return `- delete worker ${change.id}: ${change.command} (directory: ${quoted(change.directory)}, processes: ${change.processes})`;
5039
5111
  case "create-job": return `+ create scheduled job: ${change.command} (${change.frequency}${change.cron ? ` ${change.cron}` : ""})`;
@@ -5041,6 +5113,82 @@ function describeChange(change) {
5041
5113
  }
5042
5114
  }
5043
5115
  //#endregion
5116
+ //#region src/commands/sites/apply/nginx.ts
5117
+ /** The variables of Forge Nginx templates. Forge accepts spaces inside the braces. */
5118
+ var NGINX_VARIABLES = [
5119
+ "DIRECTORY",
5120
+ "DOMAINS",
5121
+ "PATH",
5122
+ "PORT",
5123
+ "PORT_V6",
5124
+ "PROXY_PASS",
5125
+ "ROOT_PATH",
5126
+ "SERVER_PUBLIC_IP",
5127
+ "SERVER_PRIVATE_IP",
5128
+ "SITE",
5129
+ "SITE_ID",
5130
+ "USER"
5131
+ ];
5132
+ /** The variables that need the server, not only the site. */
5133
+ var SERVER_VARIABLES = /* @__PURE__ */ new Set(["SERVER_PUBLIC_IP", "SERVER_PRIVATE_IP"]);
5134
+ var VARIABLE = /\{\{\s*([^{}]*?)\s*\}\}/g;
5135
+ /** The names of the variables in a template, each once, in order. */
5136
+ function templateVariables(template) {
5137
+ return [...new Set([...template.matchAll(VARIABLE)].map((match) => match[1]))];
5138
+ }
5139
+ /**
5140
+ * Exit with a validation error when the template has a variable that Forge does not define.
5141
+ */
5142
+ function assertNginxTemplate(template, source) {
5143
+ const known = new Set(NGINX_VARIABLES);
5144
+ const unknown = templateVariables(template).filter((name) => !known.has(name));
5145
+ 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(", ")}`]);
5146
+ }
5147
+ /** True when rendering the template needs the attributes of the server. */
5148
+ function needsServer(template) {
5149
+ return templateVariables(template).some((name) => SERVER_VARIABLES.has(name));
5150
+ }
5151
+ /** `PHP 8.3`, `php83` and `php8.3` all give `8.3`. */
5152
+ function phpSocketVersion(version) {
5153
+ const digits = version.replaceAll(/\D/g, "");
5154
+ if (digits.length < 2) throw new ValidationError(`Cannot read the PHP version ${version} of the site`, "nginx");
5155
+ return `${digits[0]}.${digits.slice(1)}`;
5156
+ }
5157
+ /**
5158
+ * The value of each variable for a site, as Forge renders its templates.
5159
+ * A server is needed only for the `SERVER_*` variables.
5160
+ */
5161
+ function nginxValues(site, server = null) {
5162
+ const root = siteWorkDirectory(site);
5163
+ const path = isPathInside(site.web_directory, root) ? normalizePath(site.web_directory) : normalizePath(`${root}/${site.web_directory}`);
5164
+ const directory = path === root ? "/" : path.slice(root.length);
5165
+ const defaultServer = site.name === "default" ? " default_server" : "";
5166
+ const isolated = site.isolated ? `-${site.user}` : "";
5167
+ return {
5168
+ DIRECTORY: directory,
5169
+ DOMAINS: [site.name, ...site.aliases ?? []].join(" "),
5170
+ PATH: path,
5171
+ PORT: `80${defaultServer}`,
5172
+ PORT_V6: `[::]:80${defaultServer}`,
5173
+ get PROXY_PASS() {
5174
+ return `unix:/var/run/php/php${phpSocketVersion(site.php_version)}-fpm${isolated}.sock`;
5175
+ },
5176
+ ROOT_PATH: root,
5177
+ SERVER_PUBLIC_IP: server?.ip_address ?? "",
5178
+ SERVER_PRIVATE_IP: server?.private_ip_address ?? "",
5179
+ SITE: site.name,
5180
+ SITE_ID: String(site.id),
5181
+ USER: site.user
5182
+ };
5183
+ }
5184
+ /**
5185
+ * Replace each variable of the template with its value for the site.
5186
+ */
5187
+ function renderNginx(template, site, server = null) {
5188
+ const values = nginxValues(site, server);
5189
+ return template.replaceAll(VARIABLE, (match, name) => name in values ? values[name] : match);
5190
+ }
5191
+ //#endregion
5044
5192
  //#region src/commands/sites/apply/apply.ts
5045
5193
  /**
5046
5194
  * Read the state of Forge for a site file, and run a plan against Forge.
@@ -5090,6 +5238,7 @@ async function readRemoteState(file, serverId, ctx) {
5090
5238
  site_id: siteId
5091
5239
  }, ctx)).data.map((d) => d.id) : []);
5092
5240
  }
5241
+ if (file.nginxTemplate !== void 0) state.nginx = site ? await readNginx(file.nginxTemplate, serverId, site, ctx) : null;
5093
5242
  if (file.schedule) {
5094
5243
  const siteJobs = siteId ? (await listScheduledJobs({
5095
5244
  server_id: serverId,
@@ -5102,6 +5251,18 @@ async function readRemoteState(file, serverId, ctx) {
5102
5251
  return state;
5103
5252
  }
5104
5253
  /**
5254
+ * Read the Nginx configuration of a site on Forge and render the Nginx file for the site.
5255
+ */
5256
+ async function readNginx(template, serverId, site, ctx) {
5257
+ return {
5258
+ remote: (await getNginxConfig({
5259
+ server_id: serverId,
5260
+ site_id: String(site.id)
5261
+ }, ctx)).data,
5262
+ rendered: renderNginx(template, site, needsServer(template) ? (await getServer({ server_id: serverId }, ctx)).data : null)
5263
+ };
5264
+ }
5265
+ /**
5105
5266
  * Exit with a validation error when the site does not exist and the site file cannot create it.
5106
5267
  */
5107
5268
  function assertCanCreateSite(file, state) {
@@ -5138,10 +5299,12 @@ function setEnvValues(content, values) {
5138
5299
  for (const [name, value] of pending) result.push(`${name}=${formatEnvValue(value)}`);
5139
5300
  return `${result.join("\n")}\n`;
5140
5301
  }
5302
+ /** Forge clones the repository with the key of the server: the git host must accept it. */
5303
+ var REPOSITORY_HINT = (serverId) => `Check that the git host accepts the key of the server: forge servers public-key ${serverId}`;
5141
5304
  var defaultSleep = (ms) => new Promise((resolve) => {
5142
5305
  setTimeout(resolve, ms);
5143
5306
  });
5144
- async function waitForSite(serverId, siteId, ctx, options) {
5307
+ async function waitForSite(file, serverId, siteId, ctx, options) {
5145
5308
  const sleep = options.sleep ?? defaultSleep;
5146
5309
  const interval = options.pollIntervalMs ?? 5e3;
5147
5310
  const timeout = options.timeoutMs ?? 6e5;
@@ -5151,7 +5314,7 @@ async function waitForSite(serverId, siteId, ctx, options) {
5151
5314
  site_id: siteId
5152
5315
  }, ctx)).data;
5153
5316
  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"]);
5317
+ 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
5318
  await sleep(interval);
5156
5319
  }
5157
5320
  }
@@ -5182,9 +5345,10 @@ async function applyDatabase(file, plan, state, serverId, ctx, options) {
5182
5345
  ...databaseId === void 0 ? {} : { database_ids: [databaseId] }
5183
5346
  }, ctx);
5184
5347
  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);
5348
+ const keys = databaseEnvKeys(file.database);
5349
+ env.set(keys.name, change.database);
5350
+ env.set(keys.user, change.name);
5351
+ env.set(keys.password, password);
5188
5352
  }
5189
5353
  if (databaseId !== void 0) ids.database_id = databaseId;
5190
5354
  return {
@@ -5193,6 +5357,29 @@ async function applyDatabase(file, plan, state, serverId, ctx, options) {
5193
5357
  };
5194
5358
  }
5195
5359
  /**
5360
+ * Write the rendered Nginx file when it differs from Forge. After a change of the site or its
5361
+ * aliases, read the site again: the web directory and the domains are template variables.
5362
+ */
5363
+ async function applyNginx(template, plan, serverId, site, ctx, options) {
5364
+ const siteChanged = plan.changes.some((change) => change.action === "update-site" || change.action === "add-alias");
5365
+ if (!plan.changes.some((change) => change.action === "update-nginx") && !siteChanged) return;
5366
+ const params = {
5367
+ server_id: serverId,
5368
+ site_id: String(site.id)
5369
+ };
5370
+ const { remote, rendered } = await readNginx(template, serverId, siteChanged ? (await getSite(params, ctx)).data : site, ctx);
5371
+ const diff = unifiedDiff(remote, rendered);
5372
+ if (!diff) return;
5373
+ options.onChange?.({
5374
+ action: "update-nginx",
5375
+ diff
5376
+ });
5377
+ await updateNginxConfig({
5378
+ ...params,
5379
+ content: rendered
5380
+ }, ctx);
5381
+ }
5382
+ /**
5196
5383
  * Run the changes of a plan for a site that exists.
5197
5384
  */
5198
5385
  async function applyChanges(file, plan, state, serverId, site, generatedEnv, ctx, options) {
@@ -5250,6 +5437,7 @@ async function applyChanges(file, plan, state, serverId, site, generatedEnv, ctx
5250
5437
  content: setEnvValues(content, env)
5251
5438
  }, ctx);
5252
5439
  }
5440
+ if (file.nginxTemplate !== void 0) await applyNginx(file.nginxTemplate, plan, serverId, site, ctx, options);
5253
5441
  for (const change of creations) {
5254
5442
  options.onChange?.(change);
5255
5443
  if (change.action === "create-worker") await createDaemon({
@@ -5309,7 +5497,7 @@ async function applySiteFile(file, serverId, ctx, options = {}) {
5309
5497
  ...settings.branch ? { branch: settings.branch } : {},
5310
5498
  ...database.ids
5311
5499
  }, ctx);
5312
- await waitForSite(serverId, String(created.data.id), ctx, options);
5500
+ await waitForSite(file, serverId, String(created.data.id), ctx, options);
5313
5501
  state = await readRemoteState(file, serverId, ctx);
5314
5502
  plan = buildPlan(file, state);
5315
5503
  }
@@ -5364,6 +5552,17 @@ var ScheduledJobSchema = v.pipe(v.strictObject({
5364
5552
  frequency: v.picklist(SCHEDULE_FREQUENCIES),
5365
5553
  cron: v.optional(text)
5366
5554
  }), v.check((job) => job.frequency === "custom" === (job.cron !== void 0), "cron is required with the custom frequency, and only with it"));
5555
+ var envName = v.pipe(v.string(), v.regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "invalid .env variable name"));
5556
+ /** The .env keys that a new database user gets. Default: the Laravel names. */
5557
+ var DatabaseEnvSchema = v.pipe(v.strictObject({
5558
+ name: v.optional(envName),
5559
+ user: v.optional(envName),
5560
+ password: v.optional(envName)
5561
+ }), v.check((env) => (/* @__PURE__ */ new Set([
5562
+ env.name ?? "DB_DATABASE",
5563
+ env.user ?? "DB_USERNAME",
5564
+ env.password ?? "DB_PASSWORD"
5565
+ ])).size === 3, "the .env keys of the database must be different"));
5367
5566
  var SiteFileSchema = v.strictObject({
5368
5567
  $schema: v.optional(v.string()),
5369
5568
  server: v.union([text, v.pipe(v.number(), v.integer(), v.minValue(1))]),
@@ -5379,13 +5578,15 @@ var SiteFileSchema = v.strictObject({
5379
5578
  })),
5380
5579
  database: v.optional(v.strictObject({
5381
5580
  name: text,
5382
- user: text
5581
+ user: text,
5582
+ env: v.optional(DatabaseEnvSchema)
5383
5583
  })),
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([
5584
+ env: v.optional(v.record(envName, v.union([
5385
5585
  v.string(),
5386
5586
  v.number(),
5387
5587
  v.boolean()
5388
5588
  ]))),
5589
+ nginx: v.optional(text),
5389
5590
  workers: v.optional(v.array(WorkerSchema)),
5390
5591
  schedule: v.optional(v.array(ScheduledJobSchema))
5391
5592
  });
@@ -5403,7 +5604,8 @@ function parseSiteFile(data, source = "site file") {
5403
5604
  return result.output;
5404
5605
  }
5405
5606
  /**
5406
- * Read and validate a site file from disk.
5607
+ * Read and validate a site file from disk, with its Nginx file: a relative `nginx` path is
5608
+ * read against the folder of the site file.
5407
5609
  */
5408
5610
  function readSiteFile(path) {
5409
5611
  let content;
@@ -5418,7 +5620,17 @@ function readSiteFile(path) {
5418
5620
  } catch (error) {
5419
5621
  throw new ValidationError(`Site file ${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, "file");
5420
5622
  }
5421
- return parseSiteFile(data, `site file ${path}`);
5623
+ const file = parseSiteFile(data, `site file ${path}`);
5624
+ if (file.nginx) {
5625
+ const nginxPath = resolve(dirname(path), file.nginx);
5626
+ try {
5627
+ file.nginxTemplate = readFileSync(nginxPath, "utf8");
5628
+ } catch (error) {
5629
+ throw new ValidationError(`Cannot read Nginx file ${nginxPath}: ${error instanceof Error ? error.message : String(error)}`, "nginx");
5630
+ }
5631
+ assertNginxTemplate(file.nginxTemplate, nginxPath);
5632
+ }
5633
+ return file;
5422
5634
  }
5423
5635
  //#endregion
5424
5636
  //#region src/commands/sites/apply/handler.ts
@@ -5610,9 +5822,15 @@ ${colors.bold("SITE FILE:")}
5610
5822
  A key that is absent is not managed: apply does not read or change it.
5611
5823
  type, php, webDirectory, repository Create the site, or update php, web directory, branch
5612
5824
  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
5825
+ database { name, user, env } Create when missing; a new user gets a random
5826
+ password, written into the site .env only.
5827
+ env maps { name, user, password } to .env keys
5828
+ (default DB_DATABASE, DB_USERNAME, DB_PASSWORD)
5615
5829
  env Add missing keys, never overwrite a value
5830
+ nginx Nginx file, relative to the site file, with Forge
5831
+ template variables such as {{PATH}}, {{SITE_ID}},
5832
+ {{PROXY_PASS}}: rendered for the site, written
5833
+ when it differs (an unknown variable exits 3)
5616
5834
  workers, schedule Create missing ones, delete the ones of the site
5617
5835
  that the file does not declare ([] deletes all)
5618
5836
 
@@ -6493,7 +6711,7 @@ function isVersionRequest({ command, options }) {
6493
6711
  }
6494
6712
  //#endregion
6495
6713
  //#region src/cli.ts
6496
- var VERSION = "0.5.2";
6714
+ var VERSION = "0.5.3";
6497
6715
  function showHelp() {
6498
6716
  console.log(`
6499
6717
  ${colors.bold("forge")} v${VERSION}