@studiometa/forge-cli 0.5.1 → 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.
Files changed (36) hide show
  1. package/README.md +105 -1
  2. package/dist/cli.js +1204 -44
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/completion.d.ts.map +1 -1
  5. package/dist/commands/servers/command.d.ts.map +1 -1
  6. package/dist/commands/servers/handlers.d.ts +5 -0
  7. package/dist/commands/servers/handlers.d.ts.map +1 -1
  8. package/dist/commands/servers/help.d.ts.map +1 -1
  9. package/dist/commands/sites/apply/apply.d.ts +61 -0
  10. package/dist/commands/sites/apply/apply.d.ts.map +1 -0
  11. package/dist/commands/sites/apply/handler.d.ts +8 -0
  12. package/dist/commands/sites/apply/handler.d.ts.map +1 -0
  13. package/dist/commands/sites/apply/nginx.d.ts +31 -0
  14. package/dist/commands/sites/apply/nginx.d.ts.map +1 -0
  15. package/dist/commands/sites/apply/ownership.d.ts +88 -0
  16. package/dist/commands/sites/apply/ownership.d.ts.map +1 -0
  17. package/dist/commands/sites/apply/plan.d.ts +147 -0
  18. package/dist/commands/sites/apply/plan.d.ts.map +1 -0
  19. package/dist/commands/sites/apply/site-file.d.ts +94 -0
  20. package/dist/commands/sites/apply/site-file.d.ts.map +1 -0
  21. package/dist/commands/sites/apply/test-fixtures.d.ts +104 -0
  22. package/dist/commands/sites/apply/test-fixtures.d.ts.map +1 -0
  23. package/dist/commands/sites/command.d.ts.map +1 -1
  24. package/dist/commands/sites/help.d.ts.map +1 -1
  25. package/dist/commands/ssh-keys/sync.d.ts.map +1 -1
  26. package/dist/context.d.ts +15 -1
  27. package/dist/context.d.ts.map +1 -1
  28. package/dist/{error-handler-Adi84RcF.js → error-handler-s-BOJ1-h.js} +16 -4
  29. package/dist/error-handler-s-BOJ1-h.js.map +1 -0
  30. package/dist/index.js +1 -1
  31. package/dist/utils/args.d.ts.map +1 -1
  32. package/dist/utils/resolve.d.ts +19 -13
  33. package/dist/utils/resolve.d.ts.map +1 -1
  34. package/package.json +6 -4
  35. package/schemas/site.schema.json +149 -0
  36. package/dist/error-handler-Adi84RcF.js.map +0 -1
package/dist/cli.js CHANGED
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env node
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 } from "./error-handler-Adi84RcF.js";
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, createDatabaseUser, createMonitor, createNginxTemplate, createRedirectRule, createScheduledJob, createSecurityRule, createServer, createSshKey, createStorageProvider, deleteBackupConfig, 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, 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 } from "node:path";
7
+ import { dirname, join, posix, resolve } from "node:path";
8
8
  import { diff } from "node:util";
9
+ import { createHash, randomBytes } from "node:crypto";
10
+ import * as v from "valibot";
9
11
  import { spawnSync } from "node:child_process";
10
- import { createHash } from "node:crypto";
11
12
  //#region src/audit.ts
12
13
  /**
13
14
  * Shared audit logger for CLI write operations.
@@ -68,59 +69,65 @@ function createCommandRouter(config) {
68
69
  * Smart resolution utilities for --server and --site options.
69
70
  *
70
71
  * Accepts numeric IDs (used as-is) or plain strings (resolved by name/partial match).
72
+ * In exact mode (`--exact` or `FORGE_EXACT_MATCH=1`), a plain string must match a name exactly.
71
73
  */
74
+ var EXACT_MATCH_HINT = "Pass the numeric ID or the exact name, or turn off exact matching: remove --exact and unset FORGE_EXACT_MATCH";
75
+ function formatResources(resources) {
76
+ return resources.map((r) => ` ${r.name} (${r.id})`).join("\n");
77
+ }
78
+ /**
79
+ * Match a --server or --site value against a list of resources.
80
+ *
81
+ * - Numeric string → returned as-is
82
+ * - Exact name (case-insensitive) → its ID
83
+ * - Exact mode: no exact name → ValidationError listing the near (partial) matches
84
+ * - Otherwise one partial match → its ID; 0 or 2+ partial matches → ValidationError
85
+ */
86
+ function matchResourceId(value, resources, kind, scope, exactMatch) {
87
+ if (/^\d+$/.test(value)) return value;
88
+ const { exact, partial } = matchByName(resources, value, (r) => r.name);
89
+ if (exact.length === 1) return String(exact[0].id);
90
+ if (exactMatch && exact.length === 0) {
91
+ const candidates = partial.length > 0 ? `Near matches:\n${formatResources(partial)}` : `Available ${kind}s:\n${formatResources(resources)}`;
92
+ throw new ValidationError(`No ${kind} named "${value}"${scope} (exact match required)`, kind, [candidates, EXACT_MATCH_HINT]);
93
+ }
94
+ if (partial.length === 0) throw new ValidationError(`No ${kind} found matching "${value}"${scope}`, kind, [`Available ${kind}s:\n${formatResources(resources)}`]);
95
+ if (partial.length > 1) throw new ValidationError(`Ambiguous ${kind} name "${value}" — ${partial.length} matches found`, kind, [`Matching ${kind}s:\n${formatResources(partial)}`, "Use a more specific name or pass the numeric ID"]);
96
+ return String(partial[0].id);
97
+ }
72
98
  /**
73
99
  * Resolve a --server value to a numeric server ID string.
74
100
  *
75
101
  * - Numeric string → returned as-is (no API call)
76
- * - Plain string → fetch all servers, exact match first, then partial match
77
- * - 0 matches → ValidationError listing available servers
78
- * - 2+ matches → ValidationError listing ambiguous matches
102
+ * - Plain string → fetch all servers, then see {@link matchServerId}
79
103
  */
80
104
  async function resolveServerId(value, execCtx) {
81
105
  if (/^\d+$/.test(value)) return value;
82
- return matchServerId(value, (await listServers({}, execCtx)).data);
106
+ return matchServerId(value, (await listServers({}, execCtx)).data, execCtx.exactMatch);
83
107
  }
84
108
  /**
85
109
  * Resolve a --server value to a numeric server ID string, against a list of servers
86
- * that is already loaded. Same rules as {@link resolveServerId}, with no API call.
110
+ * that is already loaded, with no API call.
111
+ *
112
+ * - Exact name match first, then a single partial match (unless `exactMatch`)
113
+ * - 0 matches → ValidationError listing available servers (near matches in exact mode)
114
+ * - 2+ matches → ValidationError listing ambiguous matches
87
115
  */
88
- function matchServerId(value, servers) {
89
- if (/^\d+$/.test(value)) return value;
90
- const { exact, partial } = matchByName(servers, value, (s) => s.name);
91
- if (exact.length === 1) return String(exact[0].id);
92
- if (partial.length === 0) {
93
- const available = servers.map((s) => ` ${s.name} (${s.id})`).join("\n");
94
- throw new ValidationError(`No server found matching "${value}"`, "server", [`Available servers:\n${available}`]);
95
- }
96
- if (partial.length > 1) {
97
- const matches = partial.map((s) => ` ${s.name} (${s.id})`).join("\n");
98
- throw new ValidationError(`Ambiguous server name "${value}" — ${partial.length} matches found`, "server", [`Matching servers:\n${matches}`, "Use a more specific name or pass the numeric ID"]);
99
- }
100
- return String(partial[0].id);
116
+ function matchServerId(value, servers, exactMatch) {
117
+ return matchResourceId(value, servers, "server", "", exactMatch);
101
118
  }
102
119
  /**
103
120
  * Resolve a --site value to a numeric site ID string.
104
121
  *
105
122
  * - Numeric string → returned as-is (no API call)
106
- * - Plain string → fetch all sites for the server, exact domain match first, then partial
107
- * - 0 matches → ValidationError listing available sites
123
+ * - Plain string → fetch all sites for the server, exact domain match first, then a single
124
+ * partial match (unless `execCtx.exactMatch`)
125
+ * - 0 matches → ValidationError listing available sites (near matches in exact mode)
108
126
  * - 2+ matches → ValidationError listing ambiguous matches
109
127
  */
110
128
  async function resolveSiteId(value, serverId, execCtx) {
111
129
  if (/^\d+$/.test(value)) return value;
112
- const sites = (await listSites({ server_id: serverId }, execCtx)).data;
113
- const { exact, partial } = matchByName(sites, value, (s) => s.name);
114
- if (exact.length === 1) return String(exact[0].id);
115
- if (partial.length === 0) {
116
- const available = sites.map((s) => ` ${s.name} (${s.id})`).join("\n");
117
- throw new ValidationError(`No site found matching "${value}" on server ${serverId}`, "site", [`Available sites:\n${available}`]);
118
- }
119
- if (partial.length > 1) {
120
- const matches = partial.map((s) => ` ${s.name} (${s.id})`).join("\n");
121
- throw new ValidationError(`Ambiguous site name "${value}" — ${partial.length} matches found`, "site", [`Matching sites:\n${matches}`, "Use a more specific name or pass the numeric ID"]);
122
- }
123
- return String(partial[0].id);
130
+ return matchResourceId(value, (await listSites({ server_id: serverId }, execCtx)).data, "site", ` on server ${serverId}`, execCtx.exactMatch);
124
131
  }
125
132
  //#endregion
126
133
  //#region src/commands/backups/options.ts
@@ -453,8 +460,8 @@ _forge_completions() {
453
460
 
454
461
  # Subcommands for each command
455
462
  local config_cmds="set get delete"
456
- local servers_cmds="list ls get create reboot"
457
- local sites_cmds="list ls get"
463
+ local servers_cmds="list ls get public-key create reboot"
464
+ local sites_cmds="list ls get apply"
458
465
  local deployments_cmds="list ls deploy logs log script update-script"
459
466
  local databases_cmds="list ls get"
460
467
  local database_users_cmds="list ls get create delete"
@@ -479,7 +486,7 @@ _forge_completions() {
479
486
  local completion_cmds="bash zsh fish"
480
487
 
481
488
  # Global options
482
- options="--token --server --site -f --format --no-color -h --help -v --version --name --command --content --from --to --provider --frequency --cron --type --operator --threshold --minutes --key --port --ip-address --user --private --dry-run --script --servers --domain --password --credential-id --region --size --project-type --directory --file --script-file --check --all-servers --prune --ubuntu-version --php-version --database --private-ip --ssh-port --tag --storage-provider --databases --retention --time --day --bucket --notification-email --include-new-databases --endpoint --access-key --secret-key-file --assume-role"
489
+ options="--token --server --site --exact -f --format --no-color -h --help -v --version --name --command --content --from --to --provider --frequency --cron --type --operator --threshold --minutes --key --port --ip-address --user --private --dry-run --script --servers --domain --password --credential-id --region --size --project-type --directory --file --script-file --check --all-servers --prune --ubuntu-version --php-version --database --private-ip --ssh-port --tag --storage-provider --databases --retention --time --day --bucket --notification-email --include-new-databases --endpoint --access-key --secret-key-file --assume-role"
483
490
 
484
491
  # Format options
485
492
  local formats="json human table"
@@ -665,6 +672,7 @@ _forge() {
665
672
  'list:List all servers'
666
673
  'ls:List all servers (alias)'
667
674
  'get:Get server details'
675
+ 'public-key:Print the public key of the forge user'
668
676
  'create:Create a server'
669
677
  'reboot:Reboot a server'
670
678
  )
@@ -676,6 +684,7 @@ _forge() {
676
684
  'list:List sites'
677
685
  'ls:List sites (alias)'
678
686
  'get:Get site details'
687
+ 'apply:Make a site match a site file'
679
688
  )
680
689
  _describe 'sites command' sites_cmds
681
690
  ;;
@@ -915,6 +924,7 @@ _forge() {
915
924
  '--token[API token]:token:' \\
916
925
  '--server[Server ID]:server id:' \\
917
926
  '--site[Site ID]:site id:' \\
927
+ '--exact[Match server and site names exactly]' \\
918
928
  '(-f --format)'{-f,--format}'[Output format]:format:(json human table)' \\
919
929
  '--no-color[Disable colored output]' \\
920
930
  '(-h --help)'{-h,--help}'[Show help]' \\
@@ -1021,6 +1031,7 @@ complete -c forge -f -n "__fish_seen_subcommand_from config" -a "delete" -d "Del
1021
1031
  complete -c forge -f -n "__fish_seen_subcommand_from servers s" -a "list" -d "List all servers"
1022
1032
  complete -c forge -f -n "__fish_seen_subcommand_from servers s" -a "ls" -d "List all servers (alias)"
1023
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"
1024
1035
  complete -c forge -f -n "__fish_seen_subcommand_from servers s" -a "create" -d "Create a server"
1025
1036
  complete -c forge -f -n "__fish_seen_subcommand_from servers s" -a "reboot" -d "Reboot a server"
1026
1037
 
@@ -1028,6 +1039,7 @@ complete -c forge -f -n "__fish_seen_subcommand_from servers s" -a "reboot" -d "
1028
1039
  complete -c forge -f -n "__fish_seen_subcommand_from sites" -a "list" -d "List sites"
1029
1040
  complete -c forge -f -n "__fish_seen_subcommand_from sites" -a "ls" -d "List sites (alias)"
1030
1041
  complete -c forge -f -n "__fish_seen_subcommand_from sites" -a "get" -d "Get site details"
1042
+ complete -c forge -f -n "__fish_seen_subcommand_from sites" -a "apply" -d "Make a site match a site file"
1031
1043
 
1032
1044
  # deployments subcommands
1033
1045
  complete -c forge -f -n "__fish_seen_subcommand_from deployments d" -a "list" -d "List deployments"
@@ -1175,6 +1187,7 @@ complete -c forge -f -n "__fish_seen_subcommand_from completion" -a "fish" -d "I
1175
1187
  complete -c forge -l token -d "API token" -r
1176
1188
  complete -c forge -l server -d "Server ID" -r
1177
1189
  complete -c forge -l site -d "Site ID" -r
1190
+ complete -c forge -l exact -d "Match server and site names exactly"
1178
1191
  complete -c forge -s f -l format -d "Output format" -xa "json human table"
1179
1192
  complete -c forge -l no-color -d "Disable colored output"
1180
1193
  complete -c forge -s h -l help -d "Show help"
@@ -4279,6 +4292,23 @@ async function serversGet(args, ctx) {
4279
4292
  ctx.formatter.output(result.data);
4280
4293
  }, ctx.formatter);
4281
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
+ }
4282
4312
  var CREATE_USAGE$1 = "forge servers create --name <name> --ip-address <ip> [options]";
4283
4313
  /**
4284
4314
  * Create a server. Defaults to a Custom VPS app server on Ubuntu 26.04.
@@ -4352,6 +4382,7 @@ var handleServersCommand = createCommandRouter({
4352
4382
  list: serversList,
4353
4383
  ls: serversList,
4354
4384
  get: [serversGet, "args"],
4385
+ "public-key": [serversPublicKey, "args"],
4355
4386
  create: serversCreate,
4356
4387
  reboot: [serversReboot, "args"]
4357
4388
  },
@@ -4391,6 +4422,27 @@ ${colors.bold("OPTIONS:")}
4391
4422
  ${colors.bold("EXAMPLES:")}
4392
4423
  forge servers get 123
4393
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
4394
4446
  `);
4395
4447
  else if (subcommand === "create") console.log(`
4396
4448
  ${colors.bold("forge servers create")} - Create a server
@@ -4445,6 +4497,7 @@ ${colors.bold("ALIASES:")}
4445
4497
  ${colors.bold("SUBCOMMANDS:")}
4446
4498
  list, ls List all servers
4447
4499
  get <id> Get server details
4500
+ public-key <id> Print the public key of the forge user
4448
4501
  create Create a server (requires --name, and --ip-address for custom)
4449
4502
  reboot <id> Reboot a server
4450
4503
 
@@ -4576,6 +4629,1063 @@ Run ${colors.cyan("forge services <subcommand> --help")} for details.
4576
4629
  `);
4577
4630
  }
4578
4631
  //#endregion
4632
+ //#region src/commands/sites/apply/ownership.ts
4633
+ /**
4634
+ * Path and command helpers of `forge sites apply`: normalize commands so that
4635
+ * equivalent spellings compare equal, and find which site owns a daemon or a scheduled job.
4636
+ *
4637
+ * Ownership is strict on purpose: `apply` deletes the workers and scheduled jobs of a site
4638
+ * that the site file does not declare, and a server often hosts many sites.
4639
+ */
4640
+ /**
4641
+ * Normalize an absolute path: resolve `.` and `..`, collapse repeated slashes
4642
+ * and drop the trailing slash.
4643
+ */
4644
+ function normalizePath(path) {
4645
+ const normalized = posix.normalize(path.trim());
4646
+ return normalized.length > 1 && normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
4647
+ }
4648
+ /**
4649
+ * Whether `path` is `root` or inside it, compared on whole path segments:
4650
+ * `/home/forge/a.com` contains `/home/forge/a.com/current` but not `/home/forge/a.com-staging`.
4651
+ */
4652
+ function isPathInside(path, root) {
4653
+ const child = normalizePath(path);
4654
+ const parent = normalizePath(root);
4655
+ if (parent === "/") return child.startsWith("/");
4656
+ return child === parent || child.startsWith(`${parent}/`);
4657
+ }
4658
+ /**
4659
+ * Resolve `path` against `base` when it is relative.
4660
+ */
4661
+ function resolvePath(base, path) {
4662
+ return normalizePath(path.startsWith("/") ? path : posix.join(base, path));
4663
+ }
4664
+ function escapeRegExp(value) {
4665
+ return value.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
4666
+ }
4667
+ /**
4668
+ * Whether a shell command uses `root` as a whole path: `root` must start the command or follow
4669
+ * a space, a quote, `=` or a shell operator, and must end the command or be followed by `/`,
4670
+ * a space, a quote or a shell operator. `/home/forge/a.com` is not found in
4671
+ * `/home/forge/a.com-staging/artisan`, `/home/forge/a.com.old` or `/home/forge/*`.
4672
+ */
4673
+ function commandUsesPath(command, root) {
4674
+ const path = normalizePath(root);
4675
+ if (path === "/") return false;
4676
+ return new RegExp(String.raw`(?:^|[\s'"=(;&|<>:])${escapeRegExp(path)}(?=$|[/\s'";&|)<>])`).test(command);
4677
+ }
4678
+ /**
4679
+ * Split a shell command into words, with the quoting rules of a POSIX shell:
4680
+ * single quotes keep everything, double quotes and a backslash escape one character.
4681
+ */
4682
+ function splitCommand(command) {
4683
+ const words = [];
4684
+ let word = "";
4685
+ let inWord = false;
4686
+ let quote = null;
4687
+ for (let index = 0; index < command.length; index++) {
4688
+ const char = command[index];
4689
+ if (quote === "'") if (char === "'") quote = null;
4690
+ else word += char;
4691
+ else if (quote === "\"") if (char === "\"") quote = null;
4692
+ else if (char === "\\" && index + 1 < command.length && "\"\\$`".includes(command[index + 1])) word += command[++index];
4693
+ else word += char;
4694
+ else if (char === "'" || char === "\"") {
4695
+ quote = char;
4696
+ inWord = true;
4697
+ } else if (char === "\\" && index + 1 < command.length) {
4698
+ word += command[++index];
4699
+ inWord = true;
4700
+ } else if (/\s/.test(char)) {
4701
+ if (inWord) words.push(word);
4702
+ word = "";
4703
+ inWord = false;
4704
+ } else {
4705
+ word += char;
4706
+ inWord = true;
4707
+ }
4708
+ }
4709
+ if (inWord) words.push(word);
4710
+ return words;
4711
+ }
4712
+ /**
4713
+ * Quote a word for a POSIX shell when it needs quotes.
4714
+ */
4715
+ function quoteWord(word) {
4716
+ if (word !== "" && /^[\w@%+=:,./-]+$/.test(word)) return word;
4717
+ return `'${word.replaceAll("'", String.raw`'\''`)}'`;
4718
+ }
4719
+ /**
4720
+ * Write a command in one canonical form, so that equivalent spellings compare equal:
4721
+ * quotes only where needed, single spaces, and a relative `artisan` (or `./artisan`)
4722
+ * replaced by its absolute path in `directory`.
4723
+ *
4724
+ * `php artisan queue:work 'database' --queue="default"` in `/home/forge/example.com/`
4725
+ * gives `php /home/forge/example.com/artisan queue:work database --queue=default`.
4726
+ */
4727
+ function canonicalCommand(command, directory) {
4728
+ return splitCommand(command).map((word) => {
4729
+ if (directory && (word === "artisan" || word === "./artisan")) return `${normalizePath(directory)}/artisan`;
4730
+ return word;
4731
+ }).map(quoteWord).join(" ");
4732
+ }
4733
+ /**
4734
+ * The site whose root holds `path`: the most specific root wins, so a site in
4735
+ * `/home/forge/a.com/admin` is not owned by `/home/forge/a.com`. Returns null when no site holds it.
4736
+ */
4737
+ function siteOfPath(path, sites) {
4738
+ let best = null;
4739
+ for (const site of sites) for (const root of site.roots) if (isPathInside(path, root)) {
4740
+ const length = normalizePath(root).length;
4741
+ if (!best || length > best.length) best = {
4742
+ id: site.id,
4743
+ length
4744
+ };
4745
+ }
4746
+ return best?.id ?? null;
4747
+ }
4748
+ /**
4749
+ * The sites whose roots a command uses as a path.
4750
+ */
4751
+ function sitesOfCommand(command, sites) {
4752
+ return sites.filter((site) => site.roots.some((root) => commandUsesPath(command, root))).map((site) => site.id);
4753
+ }
4754
+ /**
4755
+ * Ownership of a daemon (a Forge background process).
4756
+ *
4757
+ * - With a directory: the site that holds the directory (see {@link siteOfPath}).
4758
+ * - Without a directory: `ambiguous` when the command uses the site root, `site` only when
4759
+ * the command uses no other site root and Forge also associates the daemon with the site.
4760
+ */
4761
+ function daemonOwnership(daemon, siteId, sites, associatedWithSite) {
4762
+ if (daemon.directory && daemon.directory.trim()) return siteOfPath(daemon.directory, sites) === siteId ? "site" : "other";
4763
+ const owners = sitesOfCommand(daemon.command, sites);
4764
+ if (!owners.includes(siteId)) return "other";
4765
+ return owners.length === 1 && associatedWithSite ? "site" : "ambiguous";
4766
+ }
4767
+ /**
4768
+ * Ownership of a scheduled job. Forge lists the jobs of a site on a site route: those
4769
+ * belong to the site. A job of the server belongs to the site when its command uses the
4770
+ * site root and no other site root; when it uses several site roots, it is `ambiguous`.
4771
+ */
4772
+ function scheduledJobOwnership(job, siteId, sites, listedOnSite) {
4773
+ if (listedOnSite) return "site";
4774
+ const owners = sitesOfCommand(job.command, sites);
4775
+ if (!owners.includes(siteId)) return "other";
4776
+ return owners.length === 1 ? "site" : "ambiguous";
4777
+ }
4778
+ //#endregion
4779
+ //#region src/commands/sites/apply/plan.ts
4780
+ /** Shown in place of a generated password. */
4781
+ var GENERATED = "<generated>";
4782
+ /** The path of the site folder, e.g. `/home/forge/example.com`. */
4783
+ function siteHome(site) {
4784
+ return `/home/${site.user}/${site.name}`;
4785
+ }
4786
+ /** The directory that workers run in and relative `artisan` resolves against. */
4787
+ function siteWorkDirectory(site) {
4788
+ return inSiteHome(site, site.root_directory || "/");
4789
+ }
4790
+ /**
4791
+ * Read a directory of a site, absolute (`/home/forge/example.com/public`) or relative to the
4792
+ * site folder (`/public`), as an absolute path inside the site folder. A relative `/` never
4793
+ * becomes the root of the server.
4794
+ */
4795
+ function inSiteHome(site, directory) {
4796
+ const home = siteHome(site);
4797
+ return isPathInside(directory, home) ? normalizePath(directory) : resolvePath(home, directory.replace(/^\/+/, ""));
4798
+ }
4799
+ /** The paths that belong to each site, for ownership. */
4800
+ function siteRoots(sites) {
4801
+ return sites.map((site) => ({
4802
+ id: site.id,
4803
+ roots: [...new Set([siteHome(site), siteWorkDirectory(site)].map(normalizePath))]
4804
+ }));
4805
+ }
4806
+ /** `PHP 8.3`, `php83` and `php8.3` all give `php83`. */
4807
+ function normalizePhp(version) {
4808
+ return version ? version.toLowerCase().replaceAll(/[^a-z0-9]/g, "") : null;
4809
+ }
4810
+ /** Forge displays frequencies in title case, e.g. `Minutely`. */
4811
+ function normalizeFrequency(frequency) {
4812
+ return frequency.trim().toLowerCase();
4813
+ }
4814
+ /** The path a declared site uses before it exists, from its domain. */
4815
+ function plannedWorkDirectory(file) {
4816
+ return `/home/forge/${file.domain}`;
4817
+ }
4818
+ function sameName(a, b) {
4819
+ return a.toLowerCase() === b.toLowerCase();
4820
+ }
4821
+ /**
4822
+ * Pair declared items with existing ones that have the same key, each existing item once.
4823
+ * Returns the declared items without a match and the existing items without a match.
4824
+ */
4825
+ function matchByKey(declared, existing, declaredKey, existingKey) {
4826
+ const pool = existing.map((item) => ({
4827
+ item,
4828
+ key: existingKey(item),
4829
+ used: false
4830
+ }));
4831
+ const missing = [];
4832
+ for (const item of declared) {
4833
+ const key = declaredKey(item);
4834
+ const match = pool.find((entry) => !entry.used && entry.key === key);
4835
+ if (match) match.used = true;
4836
+ else missing.push(item);
4837
+ }
4838
+ return {
4839
+ missing,
4840
+ unmatched: pool.filter((entry) => !entry.used).map((entry) => entry.item)
4841
+ };
4842
+ }
4843
+ function planSite(file, site, changes, warnings) {
4844
+ if (!site) {
4845
+ changes.push({
4846
+ action: "create-site",
4847
+ name: file.domain,
4848
+ settings: {
4849
+ type: file.type,
4850
+ php_version: file.php,
4851
+ web_directory: file.webDirectory,
4852
+ source_control_provider: file.repository?.provider,
4853
+ repository: file.repository?.url,
4854
+ branch: file.repository?.branch
4855
+ }
4856
+ });
4857
+ return;
4858
+ }
4859
+ const updates = [];
4860
+ if (file.php && normalizePhp(site.php_version) !== normalizePhp(file.php)) updates.push({
4861
+ field: "php_version",
4862
+ from: site.php_version,
4863
+ to: file.php
4864
+ });
4865
+ if (file.webDirectory) {
4866
+ const root = siteWorkDirectory(site);
4867
+ const expected = resolvePath(root, file.webDirectory.replace(/^\/+/, ""));
4868
+ if ((isPathInside(site.web_directory, root) ? normalizePath(site.web_directory) : resolvePath(root, site.web_directory.replace(/^\/+/, ""))) !== expected) updates.push({
4869
+ field: "web_directory",
4870
+ from: site.web_directory,
4871
+ to: file.webDirectory
4872
+ });
4873
+ }
4874
+ if (file.repository?.branch && site.repository?.branch !== file.repository.branch) updates.push({
4875
+ field: "branch",
4876
+ from: site.repository?.branch ?? null,
4877
+ to: file.repository.branch
4878
+ });
4879
+ if (updates.length > 0) changes.push({
4880
+ action: "update-site",
4881
+ changes: updates
4882
+ });
4883
+ const url = site.repository?.url;
4884
+ if (file.repository && url && url.replace(/\.git$/, "") !== file.repository.url.replace(/\.git$/, "")) warnings.push(`Repository differs on Forge (${url}): apply does not change the repository of a site.`);
4885
+ if (file.type && site.app_type && !sameName(site.app_type, file.type)) warnings.push(`Site type differs on Forge (${site.app_type}): apply does not change the type of a site.`);
4886
+ }
4887
+ function planAliases(file, site, changes) {
4888
+ const existing = site ? [site.name, ...site.aliases ?? []].map(String) : [];
4889
+ for (const alias of new Set(file.aliases ?? [])) if (!existing.some((name) => sameName(name, alias))) changes.push({
4890
+ action: "add-alias",
4891
+ name: alias
4892
+ });
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
+ }
4902
+ /**
4903
+ * Plan the database and its user. Returns the names of the .env keys that get generated values.
4904
+ */
4905
+ function planDatabase(file, state, changes, warnings) {
4906
+ if (!file.database) return [];
4907
+ const { name, user } = file.database;
4908
+ const databaseExists = (state.databases ?? []).some((db) => db.name === name);
4909
+ const userExists = (state.databaseUsers ?? []).some((u) => u.name === user);
4910
+ if (!databaseExists) changes.push({
4911
+ action: "create-database",
4912
+ name
4913
+ });
4914
+ if (!userExists) {
4915
+ changes.push({
4916
+ action: "create-database-user",
4917
+ name: user,
4918
+ database: name
4919
+ });
4920
+ const keys = databaseEnvKeys(file.database);
4921
+ return [
4922
+ keys.name,
4923
+ keys.user,
4924
+ keys.password
4925
+ ];
4926
+ }
4927
+ if (!databaseExists) warnings.push(`Database user ${user} exists: apply does not give it access to the new database ${name}.`);
4928
+ return [];
4929
+ }
4930
+ function planEnv(file, state, generated, changes, warnings) {
4931
+ const remote = parseEnv(state.env ?? "");
4932
+ const keys = [];
4933
+ for (const [key, value] of Object.entries(file.env ?? {})) {
4934
+ if (generated.includes(key)) continue;
4935
+ if (!remote.has(key)) keys.push(key);
4936
+ else if (remote.get(key) !== String(value)) warnings.push(`.env key ${key} has another value on Forge: apply does not overwrite it.`);
4937
+ }
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
4961
+ });
4962
+ }
4963
+ function describeOwnership(ownership) {
4964
+ return ownership === "ambiguous" ? "its owner is not certain" : "it runs as root";
4965
+ }
4966
+ function planWorkers(file, state, changes, warnings) {
4967
+ if (!file.workers) return;
4968
+ const site = state.site;
4969
+ const workDirectory = site ? siteWorkDirectory(site) : plannedWorkDirectory(file);
4970
+ const declared = file.workers.map((worker) => {
4971
+ const directory = worker.directory ? resolvePath(workDirectory, worker.directory) : workDirectory;
4972
+ return {
4973
+ command: worker.command,
4974
+ directory,
4975
+ processes: worker.processes,
4976
+ key: [
4977
+ canonicalCommand(worker.command, directory),
4978
+ directory,
4979
+ worker.processes,
4980
+ "forge"
4981
+ ].join("\n")
4982
+ };
4983
+ });
4984
+ const roots = siteRoots(state.sites);
4985
+ const { missing, unmatched } = matchByKey(declared, site ? (state.daemons ?? []).flatMap((daemon) => {
4986
+ const ownership = daemonOwnership(daemon, site.id, roots, state.siteDaemonIds?.has(daemon.id) ?? false);
4987
+ return ownership === "other" ? [] : [{
4988
+ daemon,
4989
+ ownership
4990
+ }];
4991
+ }) : [], (worker) => worker.key, ({ daemon }) => {
4992
+ const directory = daemon.directory?.trim() ? normalizePath(daemon.directory) : null;
4993
+ return [
4994
+ canonicalCommand(daemon.command, directory),
4995
+ directory ?? workDirectory,
4996
+ daemon.processes,
4997
+ daemon.user
4998
+ ].join("\n");
4999
+ });
5000
+ for (const worker of missing) changes.push({
5001
+ action: "create-worker",
5002
+ command: worker.command,
5003
+ directory: worker.directory,
5004
+ processes: worker.processes
5005
+ });
5006
+ for (const { daemon, ownership } of unmatched) if (ownership === "site" && daemon.user !== "root") changes.push({
5007
+ action: "delete-worker",
5008
+ id: daemon.id,
5009
+ command: daemon.command,
5010
+ directory: daemon.directory,
5011
+ processes: daemon.processes
5012
+ });
5013
+ else warnings.push(`Kept daemon ${daemon.id} (${daemon.command}): not declared, but ${describeOwnership(ownership)}.`);
5014
+ }
5015
+ function planSchedule(file, state, changes, warnings) {
5016
+ if (!file.schedule) return;
5017
+ const site = state.site;
5018
+ const workDirectory = site ? siteWorkDirectory(site) : plannedWorkDirectory(file);
5019
+ const declared = file.schedule.map((job) => {
5020
+ const command = canonicalCommand(job.command, workDirectory);
5021
+ const frequency = normalizeFrequency(job.frequency);
5022
+ return {
5023
+ command,
5024
+ frequency,
5025
+ cron: job.cron,
5026
+ key: [
5027
+ command,
5028
+ "forge",
5029
+ frequency,
5030
+ frequency === "custom" ? job.cron : ""
5031
+ ].join("\n")
5032
+ };
5033
+ });
5034
+ const roots = siteRoots(state.sites);
5035
+ const seen = /* @__PURE__ */ new Set();
5036
+ const { missing, unmatched } = matchByKey(declared, site ? (state.jobs ?? []).flatMap((job) => {
5037
+ if (seen.has(job.id)) return [];
5038
+ seen.add(job.id);
5039
+ const onSite = state.siteJobIds?.has(job.id) ?? false;
5040
+ const ownership = scheduledJobOwnership(job, site.id, roots, onSite);
5041
+ return ownership === "other" ? [] : [{
5042
+ job,
5043
+ ownership,
5044
+ onSite
5045
+ }];
5046
+ }) : [], (job) => job.key, ({ job }) => {
5047
+ const frequency = normalizeFrequency(job.frequency);
5048
+ return [
5049
+ canonicalCommand(job.command, workDirectory),
5050
+ job.user,
5051
+ frequency,
5052
+ frequency === "custom" ? job.cron : ""
5053
+ ].join("\n");
5054
+ });
5055
+ for (const job of missing) changes.push({
5056
+ action: "create-job",
5057
+ command: job.command,
5058
+ frequency: job.frequency,
5059
+ ...job.frequency === "custom" ? { cron: job.cron } : {}
5060
+ });
5061
+ for (const { job, ownership, onSite } of unmatched) if (ownership === "site" && job.user !== "root") changes.push({
5062
+ action: "delete-job",
5063
+ id: job.id,
5064
+ command: job.command,
5065
+ frequency: job.frequency,
5066
+ cron: job.cron,
5067
+ onSite
5068
+ });
5069
+ else warnings.push(`Kept scheduled job ${job.id} (${job.command}): not declared, but ${describeOwnership(ownership)}.`);
5070
+ }
5071
+ /**
5072
+ * Compare the site file with the state of Forge. Keys that the file does not have are not
5073
+ * compared. The plan creates and updates what differs, and deletes only the workers and
5074
+ * scheduled jobs that surely belong to the site and that the file does not declare.
5075
+ */
5076
+ function buildPlan(file, state) {
5077
+ const changes = [];
5078
+ const warnings = [];
5079
+ planSite(file, state.site, changes, warnings);
5080
+ planAliases(file, state.site, changes);
5081
+ const generated = planDatabase(file, state, changes, warnings);
5082
+ if (file.env || generated.length > 0) planEnv(file, state, generated, changes, warnings);
5083
+ planNginx(file, state, changes);
5084
+ planWorkers(file, state, changes, warnings);
5085
+ planSchedule(file, state, changes, warnings);
5086
+ return {
5087
+ changes,
5088
+ warnings
5089
+ };
5090
+ }
5091
+ function quoted(value) {
5092
+ return value ?? "(none)";
5093
+ }
5094
+ /**
5095
+ * One line per change, for human output. Values of .env keys never show.
5096
+ */
5097
+ function describeChange(change) {
5098
+ switch (change.action) {
5099
+ case "create-site": {
5100
+ const settings = Object.entries(change.settings).filter(([, value]) => value !== void 0).map(([key, value]) => `${key}: ${value}`);
5101
+ return `+ create site ${change.name}${settings.length ? ` (${settings.join(", ")})` : ""}`;
5102
+ }
5103
+ case "update-site": return `~ update site: ${change.changes.map((c) => `${c.field} ${quoted(c.from)} → ${c.to}`).join(", ")}`;
5104
+ case "add-alias": return `+ add alias ${change.name}`;
5105
+ case "create-database": return `+ create database ${change.name}`;
5106
+ case "create-database-user": return `+ create database user ${change.name} for ${change.database} (password: ${GENERATED})`;
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}`;
5109
+ case "create-worker": return `+ create worker: ${change.command} (directory: ${change.directory}, processes: ${change.processes})`;
5110
+ case "delete-worker": return `- delete worker ${change.id}: ${change.command} (directory: ${quoted(change.directory)}, processes: ${change.processes})`;
5111
+ case "create-job": return `+ create scheduled job: ${change.command} (${change.frequency}${change.cron ? ` ${change.cron}` : ""})`;
5112
+ case "delete-job": return `- delete scheduled job ${change.id}: ${change.command} (${change.frequency.toLowerCase()} ${change.cron})`;
5113
+ }
5114
+ }
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
5192
+ //#region src/commands/sites/apply/apply.ts
5193
+ /**
5194
+ * Read the state of Forge for a site file, and run a plan against Forge.
5195
+ */
5196
+ /** Error of `apply` that is not a validation error, e.g. a site that never gets installed. */
5197
+ var ApplyError = class extends CliError {
5198
+ code = "APPLY_ERROR";
5199
+ isRecoverable = false;
5200
+ };
5201
+ /**
5202
+ * Resolve the server of the site file, always with an exact name (never a partial match).
5203
+ */
5204
+ async function resolveApplyServer(server, ctx) {
5205
+ const value = String(server);
5206
+ if (/^\d+$/.test(value)) return value;
5207
+ return matchServerId(value, (await listServers({}, ctx)).data, true);
5208
+ }
5209
+ /**
5210
+ * The site named `domain` on the server, compared without case. A partial match never counts.
5211
+ */
5212
+ function findSite(sites, domain) {
5213
+ return sites.find((site) => site.name.toLowerCase() === domain.toLowerCase()) ?? null;
5214
+ }
5215
+ /**
5216
+ * Read from Forge what the site file manages, and nothing else.
5217
+ */
5218
+ async function readRemoteState(file, serverId, ctx) {
5219
+ const sites = (await listSites({ server_id: serverId }, ctx)).data;
5220
+ const site = findSite(sites, file.domain);
5221
+ const siteId = site ? String(site.id) : void 0;
5222
+ const state = {
5223
+ sites,
5224
+ site
5225
+ };
5226
+ if (file.database) {
5227
+ state.databases = (await listDatabases({ server_id: serverId }, ctx)).data;
5228
+ state.databaseUsers = (await listDatabaseUsers({ server_id: serverId }, ctx)).data;
5229
+ }
5230
+ if (file.env || file.database) state.env = siteId ? (await getEnv({
5231
+ server_id: serverId,
5232
+ site_id: siteId
5233
+ }, ctx)).data : null;
5234
+ if (file.workers) {
5235
+ state.daemons = (await listDaemons({ server_id: serverId }, ctx)).data;
5236
+ state.siteDaemonIds = new Set(siteId ? (await listDaemons({
5237
+ server_id: serverId,
5238
+ site_id: siteId
5239
+ }, ctx)).data.map((d) => d.id) : []);
5240
+ }
5241
+ if (file.nginxTemplate !== void 0) state.nginx = site ? await readNginx(file.nginxTemplate, serverId, site, ctx) : null;
5242
+ if (file.schedule) {
5243
+ const siteJobs = siteId ? (await listScheduledJobs({
5244
+ server_id: serverId,
5245
+ site_id: siteId
5246
+ }, ctx)).data : [];
5247
+ const serverJobs = (await listScheduledJobs({ server_id: serverId }, ctx)).data;
5248
+ state.jobs = [...siteJobs, ...serverJobs];
5249
+ state.siteJobIds = new Set(siteJobs.map((job) => job.id));
5250
+ }
5251
+ return state;
5252
+ }
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
+ /**
5266
+ * Exit with a validation error when the site does not exist and the site file cannot create it.
5267
+ */
5268
+ function assertCanCreateSite(file, state) {
5269
+ if (!state.site && !file.type) throw new ValidationError(`Site ${file.domain} does not exist and the site file has no type to create it`, "type", ["Add \"type\" to the site file, e.g. \"type\": \"php\"", "Check the domain and the server"]);
5270
+ }
5271
+ /** A random password for a database user: 32 characters, letters and digits only. */
5272
+ function generatePassword() {
5273
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
5274
+ let password = "";
5275
+ while (password.length < 32) for (const byte of randomBytes(64)) if (byte < 248 && password.length < 32) password += alphabet[byte % 62];
5276
+ return password;
5277
+ }
5278
+ /** Write a value for a .env file, quoted when it needs quotes. */
5279
+ function formatEnvValue(value) {
5280
+ if (/^[\w.,:/@+-]*$/.test(value)) return value;
5281
+ if (!value.includes("'") && !value.includes("\n")) return `'${value}'`;
5282
+ return `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"").replaceAll("\n", "\\n")}"`;
5283
+ }
5284
+ /**
5285
+ * Set .env variables: replace the line of a variable that exists, append the others.
5286
+ */
5287
+ function setEnvValues(content, values) {
5288
+ const lines = content === "" ? [] : content.replace(/\n$/, "").split("\n");
5289
+ const pending = new Map(values);
5290
+ const result = lines.map((line) => {
5291
+ const name = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.]*)\s*=/.exec(line)?.[1];
5292
+ if (name && pending.has(name)) {
5293
+ const value = pending.get(name);
5294
+ pending.delete(name);
5295
+ return `${name}=${formatEnvValue(value)}`;
5296
+ }
5297
+ return line;
5298
+ });
5299
+ for (const [name, value] of pending) result.push(`${name}=${formatEnvValue(value)}`);
5300
+ return `${result.join("\n")}\n`;
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}`;
5304
+ var defaultSleep = (ms) => new Promise((resolve) => {
5305
+ setTimeout(resolve, ms);
5306
+ });
5307
+ async function waitForSite(file, serverId, siteId, ctx, options) {
5308
+ const sleep = options.sleep ?? defaultSleep;
5309
+ const interval = options.pollIntervalMs ?? 5e3;
5310
+ const timeout = options.timeoutMs ?? 6e5;
5311
+ for (let waited = 0;; waited += interval) {
5312
+ const site = (await getSite({
5313
+ server_id: serverId,
5314
+ site_id: siteId
5315
+ }, ctx)).data;
5316
+ if (site.status === "installed") return;
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)] : []]);
5318
+ await sleep(interval);
5319
+ }
5320
+ }
5321
+ /**
5322
+ * Create the database and its user. Returns their IDs and the .env values to write.
5323
+ */
5324
+ async function applyDatabase(file, plan, state, serverId, ctx, options) {
5325
+ const env = /* @__PURE__ */ new Map();
5326
+ const ids = {};
5327
+ if (!file.database) return {
5328
+ ids,
5329
+ env
5330
+ };
5331
+ let databaseId = state.databases?.find((db) => db.name === file.database.name)?.id;
5332
+ for (const change of plan.changes) if (change.action === "create-database") {
5333
+ options.onChange?.(change);
5334
+ databaseId = (await createDatabase({
5335
+ server_id: serverId,
5336
+ name: change.name
5337
+ }, ctx)).data.id;
5338
+ } else if (change.action === "create-database-user") {
5339
+ options.onChange?.(change);
5340
+ const password = (options.generatePassword ?? generatePassword)();
5341
+ const user = await createDatabaseUser({
5342
+ server_id: serverId,
5343
+ name: change.name,
5344
+ password,
5345
+ ...databaseId === void 0 ? {} : { database_ids: [databaseId] }
5346
+ }, ctx);
5347
+ ids.database_user_id = String(user.data.id);
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);
5352
+ }
5353
+ if (databaseId !== void 0) ids.database_id = databaseId;
5354
+ return {
5355
+ ids,
5356
+ env
5357
+ };
5358
+ }
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
+ /**
5383
+ * Run the changes of a plan for a site that exists.
5384
+ */
5385
+ async function applyChanges(file, plan, state, serverId, site, generatedEnv, ctx, options) {
5386
+ const params = {
5387
+ server_id: serverId,
5388
+ site_id: String(site.id)
5389
+ };
5390
+ const { env: databaseEnv } = await applyDatabase(file, plan, state, serverId, ctx, options);
5391
+ const env = new Map([...generatedEnv, ...databaseEnv]);
5392
+ const creations = [];
5393
+ const deletions = [];
5394
+ for (const change of plan.changes) switch (change.action) {
5395
+ case "update-site": {
5396
+ options.onChange?.(change);
5397
+ const update = {};
5398
+ for (const { field, to } of change.changes) {
5399
+ if (field === "php_version") update.php_version = to;
5400
+ if (field === "web_directory") update.directory = to;
5401
+ if (field === "branch") update.repository_branch = to;
5402
+ }
5403
+ await updateSite({
5404
+ ...params,
5405
+ ...update
5406
+ }, ctx);
5407
+ break;
5408
+ }
5409
+ case "add-alias":
5410
+ options.onChange?.(change);
5411
+ await createDomain({
5412
+ ...params,
5413
+ name: change.name,
5414
+ allow_wildcard_subdomains: false,
5415
+ www_redirect_type: "none"
5416
+ }, ctx);
5417
+ break;
5418
+ case "add-env":
5419
+ for (const key of change.keys) env.set(key, String(file.env?.[key]));
5420
+ break;
5421
+ case "create-worker":
5422
+ case "create-job":
5423
+ creations.push(change);
5424
+ break;
5425
+ case "delete-worker":
5426
+ case "delete-job":
5427
+ deletions.push(change);
5428
+ break;
5429
+ default: break;
5430
+ }
5431
+ if (env.size > 0) {
5432
+ const addEnv = plan.changes.find((change) => change.action === "add-env");
5433
+ if (addEnv) options.onChange?.(addEnv);
5434
+ const content = (await getEnv(params, ctx)).data;
5435
+ await updateEnv({
5436
+ ...params,
5437
+ content: setEnvValues(content, env)
5438
+ }, ctx);
5439
+ }
5440
+ if (file.nginxTemplate !== void 0) await applyNginx(file.nginxTemplate, plan, serverId, site, ctx, options);
5441
+ for (const change of creations) {
5442
+ options.onChange?.(change);
5443
+ if (change.action === "create-worker") await createDaemon({
5444
+ server_id: serverId,
5445
+ site_id: site.id,
5446
+ name: change.command.slice(0, 255),
5447
+ command: change.command,
5448
+ user: "forge",
5449
+ directory: change.directory,
5450
+ processes: change.processes
5451
+ }, ctx);
5452
+ else if (change.action === "create-job") await createScheduledJob({
5453
+ ...params,
5454
+ command: change.command,
5455
+ user: "forge",
5456
+ frequency: change.frequency,
5457
+ ...change.cron ? { cron: change.cron } : {}
5458
+ }, ctx);
5459
+ }
5460
+ for (const change of deletions) {
5461
+ options.onChange?.(change);
5462
+ if (change.action === "delete-worker") await deleteDaemon({
5463
+ server_id: serverId,
5464
+ id: String(change.id)
5465
+ }, ctx);
5466
+ else if (change.action === "delete-job") await deleteScheduledJob({
5467
+ server_id: serverId,
5468
+ id: String(change.id),
5469
+ ...change.onSite ? { site_id: params.site_id } : {}
5470
+ }, ctx);
5471
+ }
5472
+ }
5473
+ /**
5474
+ * Apply a site file: create the site when it does not exist and wait until Forge installs it,
5475
+ * then read the state again and run the other changes. Returns the plan that ran first.
5476
+ */
5477
+ async function applySiteFile(file, serverId, ctx, options = {}) {
5478
+ let state = await readRemoteState(file, serverId, ctx);
5479
+ const firstPlan = buildPlan(file, state);
5480
+ let plan = firstPlan;
5481
+ let generatedEnv = /* @__PURE__ */ new Map();
5482
+ const createSiteChange = plan.changes.find((change) => change.action === "create-site");
5483
+ if (createSiteChange && createSiteChange.action === "create-site") {
5484
+ assertCanCreateSite(file, state);
5485
+ const database = await applyDatabase(file, plan, state, serverId, ctx, options);
5486
+ generatedEnv = database.env;
5487
+ options.onChange?.(createSiteChange);
5488
+ const { settings } = createSiteChange;
5489
+ const created = await createSite({
5490
+ server_id: serverId,
5491
+ type: file.type,
5492
+ name: file.domain,
5493
+ ...settings.php_version ? { php_version: settings.php_version } : {},
5494
+ ...settings.web_directory ? { web_directory: settings.web_directory } : {},
5495
+ ...settings.source_control_provider ? { source_control_provider: settings.source_control_provider } : {},
5496
+ ...settings.repository ? { repository: settings.repository } : {},
5497
+ ...settings.branch ? { branch: settings.branch } : {},
5498
+ ...database.ids
5499
+ }, ctx);
5500
+ await waitForSite(file, serverId, String(created.data.id), ctx, options);
5501
+ state = await readRemoteState(file, serverId, ctx);
5502
+ plan = buildPlan(file, state);
5503
+ }
5504
+ const site = state.site;
5505
+ if (!site) throw new ApplyError(`Site ${file.domain} is not on server ${serverId} after its creation`);
5506
+ await applyChanges(file, plan, state, serverId, site, generatedEnv, ctx, options);
5507
+ return firstPlan;
5508
+ }
5509
+ //#endregion
5510
+ //#region src/commands/sites/apply/site-file.ts
5511
+ /**
5512
+ * The site file of `forge sites apply`: a JSON file that declares one Forge site.
5513
+ * A key that is absent is not managed; only `server` and `domain` are required.
5514
+ */
5515
+ var text = v.pipe(v.string(), v.trim(), v.nonEmpty());
5516
+ var SITE_TYPES = [
5517
+ "laravel",
5518
+ "symfony",
5519
+ "statamic",
5520
+ "wordpress",
5521
+ "phpmyadmin",
5522
+ "php",
5523
+ "nextjs",
5524
+ "nuxtjs",
5525
+ "static-html",
5526
+ "other",
5527
+ "custom"
5528
+ ];
5529
+ var SOURCE_CONTROL_PROVIDERS = [
5530
+ "github",
5531
+ "gitlab",
5532
+ "bitbucket",
5533
+ "gitlab-custom",
5534
+ "custom"
5535
+ ];
5536
+ var SCHEDULE_FREQUENCIES = [
5537
+ "minutely",
5538
+ "hourly",
5539
+ "nightly",
5540
+ "weekly",
5541
+ "monthly",
5542
+ "reboot",
5543
+ "custom"
5544
+ ];
5545
+ var WorkerSchema = v.strictObject({
5546
+ command: text,
5547
+ processes: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 1),
5548
+ directory: v.optional(text)
5549
+ });
5550
+ var ScheduledJobSchema = v.pipe(v.strictObject({
5551
+ command: text,
5552
+ frequency: v.picklist(SCHEDULE_FREQUENCIES),
5553
+ cron: v.optional(text)
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"));
5566
+ var SiteFileSchema = v.strictObject({
5567
+ $schema: v.optional(v.string()),
5568
+ server: v.union([text, v.pipe(v.number(), v.integer(), v.minValue(1))]),
5569
+ domain: text,
5570
+ aliases: v.optional(v.array(text)),
5571
+ type: v.optional(v.picklist(SITE_TYPES)),
5572
+ php: v.optional(v.pipe(v.string(), v.regex(/^php\d+$/, "php must look like php83"))),
5573
+ webDirectory: v.optional(text),
5574
+ repository: v.optional(v.strictObject({
5575
+ provider: v.picklist(SOURCE_CONTROL_PROVIDERS),
5576
+ url: text,
5577
+ branch: v.optional(text)
5578
+ })),
5579
+ database: v.optional(v.strictObject({
5580
+ name: text,
5581
+ user: text,
5582
+ env: v.optional(DatabaseEnvSchema)
5583
+ })),
5584
+ env: v.optional(v.record(envName, v.union([
5585
+ v.string(),
5586
+ v.number(),
5587
+ v.boolean()
5588
+ ]))),
5589
+ nginx: v.optional(text),
5590
+ workers: v.optional(v.array(WorkerSchema)),
5591
+ schedule: v.optional(v.array(ScheduledJobSchema))
5592
+ });
5593
+ /**
5594
+ * Validate parsed JSON as a site file. Throws a ValidationError that lists every issue.
5595
+ */
5596
+ function parseSiteFile(data, source = "site file") {
5597
+ const result = v.safeParse(SiteFileSchema, data);
5598
+ if (!result.success) {
5599
+ const issues = result.issues.map((issue) => {
5600
+ return `${v.getDotPath(issue) ?? "(root)"}: ${issue.message}`;
5601
+ });
5602
+ throw new ValidationError(`Invalid ${source}`, "file", issues);
5603
+ }
5604
+ return result.output;
5605
+ }
5606
+ /**
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.
5609
+ */
5610
+ function readSiteFile(path) {
5611
+ let content;
5612
+ try {
5613
+ content = readFileSync(path, "utf8");
5614
+ } catch (error) {
5615
+ throw new ValidationError(`Cannot read site file ${path}: ${error instanceof Error ? error.message : String(error)}`, "file");
5616
+ }
5617
+ let data;
5618
+ try {
5619
+ data = JSON.parse(content);
5620
+ } catch (error) {
5621
+ throw new ValidationError(`Site file ${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, "file");
5622
+ }
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;
5634
+ }
5635
+ //#endregion
5636
+ //#region src/commands/sites/apply/handler.ts
5637
+ /**
5638
+ * `forge sites apply --file site.json [--check]`: make a Forge site match a site file.
5639
+ * With `--check`, print the plan and exit 1 when Forge differs, without any change.
5640
+ */
5641
+ async function sitesApply(ctx, applyOptions = {}) {
5642
+ const usage = "forge sites apply --file <site.json> [--check]";
5643
+ const path = ctx.options.file;
5644
+ const check = Boolean(ctx.options.check);
5645
+ if (typeof path !== "string" || !path) exitWithValidationError("file", usage, ctx.formatter);
5646
+ await runCommand(async () => {
5647
+ const file = readSiteFile(path);
5648
+ const token = ctx.getToken();
5649
+ const execCtx = ctx.createExecutorContext(token);
5650
+ const server_id = await resolveApplyServer(file.server, execCtx);
5651
+ if (check) {
5652
+ const state = await readRemoteState(file, server_id, execCtx);
5653
+ assertCanCreateSite(file, state);
5654
+ const plan = buildPlan(file, state);
5655
+ const inSync = plan.changes.length === 0;
5656
+ if (ctx.formatter.isJson()) ctx.formatter.output({
5657
+ inSync,
5658
+ changes: plan.changes,
5659
+ warnings: plan.warnings
5660
+ });
5661
+ else {
5662
+ for (const change of plan.changes) console.log(describeChange(change));
5663
+ for (const warning of plan.warnings) ctx.formatter.warning(warning);
5664
+ if (inSync) ctx.formatter.success(`Site ${file.domain} is in sync with Forge.`);
5665
+ else ctx.formatter.error(`Site ${file.domain} differs from Forge.`);
5666
+ }
5667
+ if (!inSync) process.exitCode = ExitCode.GENERAL_ERROR;
5668
+ return;
5669
+ }
5670
+ const applied = [];
5671
+ const plan = await applySiteFile(file, server_id, execCtx, {
5672
+ ...applyOptions,
5673
+ onChange: (change) => {
5674
+ applied.push(change.action);
5675
+ if (!ctx.formatter.isJson()) console.log(describeChange(change));
5676
+ }
5677
+ });
5678
+ if (ctx.formatter.isJson()) ctx.formatter.output({
5679
+ changes: plan.changes,
5680
+ warnings: plan.warnings
5681
+ });
5682
+ else {
5683
+ for (const warning of plan.warnings) ctx.formatter.warning(warning);
5684
+ ctx.formatter.success(applied.length === 0 ? `Site ${file.domain} is in sync with Forge.` : `Site ${file.domain} applied: ${applied.length} change(s).`);
5685
+ }
5686
+ }, ctx.formatter);
5687
+ }
5688
+ //#endregion
4579
5689
  //#region src/commands/sites/handlers.ts
4580
5690
  async function sitesList(ctx) {
4581
5691
  const server = String(ctx.options.server ?? "");
@@ -4660,8 +5770,10 @@ var handleSitesCommand = createCommandRouter({
4660
5770
  list: sitesList,
4661
5771
  ls: sitesList,
4662
5772
  get: [sitesGet, "args"],
4663
- update: [sitesUpdate, "args"]
4664
- }
5773
+ update: [sitesUpdate, "args"],
5774
+ apply: (ctx) => sitesApply(ctx)
5775
+ },
5776
+ writeSubcommands: ["apply"]
4665
5777
  });
4666
5778
  //#endregion
4667
5779
  //#region src/commands/sites/help.ts
@@ -4692,6 +5804,44 @@ ${colors.bold("ARGUMENTS:")}
4692
5804
  ${colors.bold("OPTIONS:")}
4693
5805
  --server <id> Server ID (required)
4694
5806
  -f, --format <fmt> Output format: json, human, table
5807
+ `);
5808
+ else if (subcommand === "apply") console.log(`
5809
+ ${colors.bold("forge sites apply")} - Make a site match a site file
5810
+
5811
+ ${colors.bold("USAGE:")}
5812
+ forge sites apply --file <site.json> [--check] [options]
5813
+
5814
+ ${colors.bold("OPTIONS:")}
5815
+ --file <path> Site file (required), e.g. config/forge/production/site.json
5816
+ --check Print the plan and change nothing: exit 0 when Forge matches,
5817
+ exit 1 when it differs, another code on error
5818
+ -f, --format <fmt> Output format: json, human
5819
+
5820
+ ${colors.bold("SITE FILE:")}
5821
+ Only "server" (exact name or ID) and "domain" (exact site name) are required.
5822
+ A key that is absent is not managed: apply does not read or change it.
5823
+ type, php, webDirectory, repository Create the site, or update php, web directory, branch
5824
+ aliases Add missing aliases, never remove one
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)
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)
5834
+ workers, schedule Create missing ones, delete the ones of the site
5835
+ that the file does not declare ([] deletes all)
5836
+
5837
+ A worker or scheduled job is deleted only when it surely belongs to the site:
5838
+ Forge lists it on the site, or its directory (or, without a directory, its command)
5839
+ uses the site path, compared on whole path segments. Jobs of root are never deleted.
5840
+ The plan never prints .env values.
5841
+
5842
+ ${colors.bold("EXAMPLES:")}
5843
+ forge sites apply --file config/forge/production/site.json --check
5844
+ forge sites apply --file config/forge/production/site.json
4695
5845
  `);
4696
5846
  else console.log(`
4697
5847
  ${colors.bold("forge sites")} - Manage sites
@@ -4702,6 +5852,7 @@ ${colors.bold("USAGE:")}
4702
5852
  ${colors.bold("SUBCOMMANDS:")}
4703
5853
  list, ls List sites on a server
4704
5854
  get <id> Get site details
5855
+ apply Make a site match a site file (--file, --check)
4705
5856
 
4706
5857
  ${colors.bold("OPTIONS:")}
4707
5858
  --server <id> Server ID (required for most commands)
@@ -4711,6 +5862,7 @@ ${colors.bold("OPTIONS:")}
4711
5862
  ${colors.bold("EXAMPLES:")}
4712
5863
  forge sites list --server 123
4713
5864
  forge sites get 456 --server 123
5865
+ forge sites apply --file config/forge/production/site.json --check
4714
5866
 
4715
5867
  Run ${colors.cyan("forge sites <subcommand> --help")} for subcommand details.
4716
5868
  `);
@@ -5042,7 +6194,7 @@ async function resolveTargets(values, allServers, execCtx) {
5042
6194
  }));
5043
6195
  const targets = /* @__PURE__ */ new Map();
5044
6196
  for (const value of values) {
5045
- const id = Number(matchServerId(value, servers));
6197
+ const id = Number(matchServerId(value, servers, execCtx.exactMatch));
5046
6198
  targets.set(id, servers.find((s) => s.id === id)?.name ?? String(id));
5047
6199
  }
5048
6200
  return [...targets].map(([id, name]) => ({
@@ -5502,6 +6654,11 @@ var REPEATABLE = /* @__PURE__ */ new Set([
5502
6654
  "databases"
5503
6655
  ]);
5504
6656
  /**
6657
+ * Long options that never take a value, so the argument after them stays a command
6658
+ * or a positional argument (`forge --exact deployments deploy`).
6659
+ */
6660
+ var FLAGS = /* @__PURE__ */ new Set(["exact"]);
6661
+ /**
5505
6662
  * Set an option value. A repeatable option given more than once collects its string
5506
6663
  * values into an array (`--server 1 --server 2` → `["1", "2"]`).
5507
6664
  */
@@ -5522,7 +6679,7 @@ function parseArgs(argv = process.argv.slice(2)) {
5522
6679
  else {
5523
6680
  const key = arg.slice(2);
5524
6681
  const nextArg = argv[i + 1];
5525
- if (nextArg !== void 0 && isOptionValue(nextArg)) {
6682
+ if (!FLAGS.has(key) && nextArg !== void 0 && isOptionValue(nextArg)) {
5526
6683
  setOption(options, key, nextArg);
5527
6684
  i++;
5528
6685
  } else setOption(options, key, true);
@@ -5554,7 +6711,7 @@ function isVersionRequest({ command, options }) {
5554
6711
  }
5555
6712
  //#endregion
5556
6713
  //#region src/cli.ts
5557
- var VERSION = "0.5.1";
6714
+ var VERSION = "0.5.3";
5558
6715
  function showHelp() {
5559
6716
  console.log(`
5560
6717
  ${colors.bold("forge")} v${VERSION}
@@ -5577,6 +6734,7 @@ ${colors.bold("COMMANDS:")}
5577
6734
  sites Manage sites
5578
6735
  list, ls List sites (requires --server)
5579
6736
  get <id> Get site details (requires --server)
6737
+ apply Make a site match a site file (--file, --check)
5580
6738
 
5581
6739
  deployments, d Manage deployments
5582
6740
  list, ls List deployments (requires --server --site)
@@ -5710,6 +6868,7 @@ ${colors.bold("OPTIONS:")}
5710
6868
  --org <slug> Organization slug (overrides config and FORGE_ORG)
5711
6869
  --server <id> Server ID (required for server-scoped commands)
5712
6870
  --site <id> Site ID (required for site-scoped commands)
6871
+ --exact Match --server and --site names exactly (no partial match)
5713
6872
  --domain <id> Domain record ID (for certificate commands)
5714
6873
  -f, --format <fmt> Output format: json, human, table (default: human)
5715
6874
  --no-color Disable colored output
@@ -5743,6 +6902,7 @@ ${colors.bold("CREDENTIAL PRIORITY:")}
5743
6902
  ${colors.bold("ENVIRONMENT VARIABLES:")}
5744
6903
  FORGE_API_TOKEN API token
5745
6904
  FORGE_ORG Organization slug (e.g. studio-meta)
6905
+ FORGE_EXACT_MATCH Set to 1 to match --server and --site names exactly (same as --exact)
5746
6906
  NO_COLOR Disable colors
5747
6907
  XDG_CONFIG_HOME Config directory (respects XDG spec)
5748
6908
  `);