@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 +36 -1
- package/dist/cli.js +241 -23
- package/dist/cli.js.map +1 -1
- package/dist/commands/completion.d.ts.map +1 -1
- package/dist/commands/servers/command.d.ts.map +1 -1
- package/dist/commands/servers/handlers.d.ts +5 -0
- package/dist/commands/servers/handlers.d.ts.map +1 -1
- package/dist/commands/servers/help.d.ts.map +1 -1
- package/dist/commands/sites/apply/apply.d.ts +7 -0
- package/dist/commands/sites/apply/apply.d.ts.map +1 -1
- package/dist/commands/sites/apply/nginx.d.ts +31 -0
- package/dist/commands/sites/apply/nginx.d.ts.map +1 -0
- package/dist/commands/sites/apply/plan.d.ts +19 -0
- package/dist/commands/sites/apply/plan.d.ts.map +1 -1
- package/dist/commands/sites/apply/site-file.d.ts +28 -3
- package/dist/commands/sites/apply/site-file.d.ts.map +1 -1
- package/dist/commands/sites/apply/test-fixtures.d.ts +30 -0
- package/dist/commands/sites/apply/test-fixtures.d.ts.map +1 -1
- package/dist/commands/sites/help.d.ts.map +1 -1
- package/package.json +3 -3
- package/schemas/site.schema.json +28 -1
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
|
-
|
|
4872
|
-
|
|
4873
|
-
|
|
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)
|
|
4888
|
-
|
|
4889
|
-
|
|
4890
|
-
|
|
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 ===
|
|
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
|
-
|
|
5186
|
-
env.set(
|
|
5187
|
-
env.set(
|
|
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(
|
|
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
|
-
|
|
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 }
|
|
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.
|
|
6714
|
+
var VERSION = "0.5.3";
|
|
6497
6715
|
function showHelp() {
|
|
6498
6716
|
console.log(`
|
|
6499
6717
|
${colors.bold("forge")} v${VERSION}
|