@studiometa/forge-cli 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,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 { join, posix } 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
@@ -454,7 +461,7 @@ _forge_completions() {
454
461
  # Subcommands for each command
455
462
  local config_cmds="set get delete"
456
463
  local servers_cmds="list ls get create reboot"
457
- local sites_cmds="list ls get"
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"
@@ -676,6 +683,7 @@ _forge() {
676
683
  'list:List sites'
677
684
  'ls:List sites (alias)'
678
685
  'get:Get site details'
686
+ 'apply:Make a site match a site file'
679
687
  )
680
688
  _describe 'sites command' sites_cmds
681
689
  ;;
@@ -915,6 +923,7 @@ _forge() {
915
923
  '--token[API token]:token:' \\
916
924
  '--server[Server ID]:server id:' \\
917
925
  '--site[Site ID]:site id:' \\
926
+ '--exact[Match server and site names exactly]' \\
918
927
  '(-f --format)'{-f,--format}'[Output format]:format:(json human table)' \\
919
928
  '--no-color[Disable colored output]' \\
920
929
  '(-h --help)'{-h,--help}'[Show help]' \\
@@ -1028,6 +1037,7 @@ complete -c forge -f -n "__fish_seen_subcommand_from servers s" -a "reboot" -d "
1028
1037
  complete -c forge -f -n "__fish_seen_subcommand_from sites" -a "list" -d "List sites"
1029
1038
  complete -c forge -f -n "__fish_seen_subcommand_from sites" -a "ls" -d "List sites (alias)"
1030
1039
  complete -c forge -f -n "__fish_seen_subcommand_from sites" -a "get" -d "Get site details"
1040
+ complete -c forge -f -n "__fish_seen_subcommand_from sites" -a "apply" -d "Make a site match a site file"
1031
1041
 
1032
1042
  # deployments subcommands
1033
1043
  complete -c forge -f -n "__fish_seen_subcommand_from deployments d" -a "list" -d "List deployments"
@@ -1175,6 +1185,7 @@ complete -c forge -f -n "__fish_seen_subcommand_from completion" -a "fish" -d "I
1175
1185
  complete -c forge -l token -d "API token" -r
1176
1186
  complete -c forge -l server -d "Server ID" -r
1177
1187
  complete -c forge -l site -d "Site ID" -r
1188
+ complete -c forge -l exact -d "Match server and site names exactly"
1178
1189
  complete -c forge -s f -l format -d "Output format" -xa "json human table"
1179
1190
  complete -c forge -l no-color -d "Disable colored output"
1180
1191
  complete -c forge -s h -l help -d "Show help"
@@ -4576,6 +4587,893 @@ Run ${colors.cyan("forge services <subcommand> --help")} for details.
4576
4587
  `);
4577
4588
  }
4578
4589
  //#endregion
4590
+ //#region src/commands/sites/apply/ownership.ts
4591
+ /**
4592
+ * Path and command helpers of `forge sites apply`: normalize commands so that
4593
+ * equivalent spellings compare equal, and find which site owns a daemon or a scheduled job.
4594
+ *
4595
+ * Ownership is strict on purpose: `apply` deletes the workers and scheduled jobs of a site
4596
+ * that the site file does not declare, and a server often hosts many sites.
4597
+ */
4598
+ /**
4599
+ * Normalize an absolute path: resolve `.` and `..`, collapse repeated slashes
4600
+ * and drop the trailing slash.
4601
+ */
4602
+ function normalizePath(path) {
4603
+ const normalized = posix.normalize(path.trim());
4604
+ return normalized.length > 1 && normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
4605
+ }
4606
+ /**
4607
+ * Whether `path` is `root` or inside it, compared on whole path segments:
4608
+ * `/home/forge/a.com` contains `/home/forge/a.com/current` but not `/home/forge/a.com-staging`.
4609
+ */
4610
+ function isPathInside(path, root) {
4611
+ const child = normalizePath(path);
4612
+ const parent = normalizePath(root);
4613
+ if (parent === "/") return child.startsWith("/");
4614
+ return child === parent || child.startsWith(`${parent}/`);
4615
+ }
4616
+ /**
4617
+ * Resolve `path` against `base` when it is relative.
4618
+ */
4619
+ function resolvePath(base, path) {
4620
+ return normalizePath(path.startsWith("/") ? path : posix.join(base, path));
4621
+ }
4622
+ function escapeRegExp(value) {
4623
+ return value.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
4624
+ }
4625
+ /**
4626
+ * Whether a shell command uses `root` as a whole path: `root` must start the command or follow
4627
+ * a space, a quote, `=` or a shell operator, and must end the command or be followed by `/`,
4628
+ * a space, a quote or a shell operator. `/home/forge/a.com` is not found in
4629
+ * `/home/forge/a.com-staging/artisan`, `/home/forge/a.com.old` or `/home/forge/*`.
4630
+ */
4631
+ function commandUsesPath(command, root) {
4632
+ const path = normalizePath(root);
4633
+ if (path === "/") return false;
4634
+ return new RegExp(String.raw`(?:^|[\s'"=(;&|<>:])${escapeRegExp(path)}(?=$|[/\s'";&|)<>])`).test(command);
4635
+ }
4636
+ /**
4637
+ * Split a shell command into words, with the quoting rules of a POSIX shell:
4638
+ * single quotes keep everything, double quotes and a backslash escape one character.
4639
+ */
4640
+ function splitCommand(command) {
4641
+ const words = [];
4642
+ let word = "";
4643
+ let inWord = false;
4644
+ let quote = null;
4645
+ for (let index = 0; index < command.length; index++) {
4646
+ const char = command[index];
4647
+ if (quote === "'") if (char === "'") quote = null;
4648
+ else word += char;
4649
+ else if (quote === "\"") if (char === "\"") quote = null;
4650
+ else if (char === "\\" && index + 1 < command.length && "\"\\$`".includes(command[index + 1])) word += command[++index];
4651
+ else word += char;
4652
+ else if (char === "'" || char === "\"") {
4653
+ quote = char;
4654
+ inWord = true;
4655
+ } else if (char === "\\" && index + 1 < command.length) {
4656
+ word += command[++index];
4657
+ inWord = true;
4658
+ } else if (/\s/.test(char)) {
4659
+ if (inWord) words.push(word);
4660
+ word = "";
4661
+ inWord = false;
4662
+ } else {
4663
+ word += char;
4664
+ inWord = true;
4665
+ }
4666
+ }
4667
+ if (inWord) words.push(word);
4668
+ return words;
4669
+ }
4670
+ /**
4671
+ * Quote a word for a POSIX shell when it needs quotes.
4672
+ */
4673
+ function quoteWord(word) {
4674
+ if (word !== "" && /^[\w@%+=:,./-]+$/.test(word)) return word;
4675
+ return `'${word.replaceAll("'", String.raw`'\''`)}'`;
4676
+ }
4677
+ /**
4678
+ * Write a command in one canonical form, so that equivalent spellings compare equal:
4679
+ * quotes only where needed, single spaces, and a relative `artisan` (or `./artisan`)
4680
+ * replaced by its absolute path in `directory`.
4681
+ *
4682
+ * `php artisan queue:work 'database' --queue="default"` in `/home/forge/example.com/`
4683
+ * gives `php /home/forge/example.com/artisan queue:work database --queue=default`.
4684
+ */
4685
+ function canonicalCommand(command, directory) {
4686
+ return splitCommand(command).map((word) => {
4687
+ if (directory && (word === "artisan" || word === "./artisan")) return `${normalizePath(directory)}/artisan`;
4688
+ return word;
4689
+ }).map(quoteWord).join(" ");
4690
+ }
4691
+ /**
4692
+ * The site whose root holds `path`: the most specific root wins, so a site in
4693
+ * `/home/forge/a.com/admin` is not owned by `/home/forge/a.com`. Returns null when no site holds it.
4694
+ */
4695
+ function siteOfPath(path, sites) {
4696
+ let best = null;
4697
+ for (const site of sites) for (const root of site.roots) if (isPathInside(path, root)) {
4698
+ const length = normalizePath(root).length;
4699
+ if (!best || length > best.length) best = {
4700
+ id: site.id,
4701
+ length
4702
+ };
4703
+ }
4704
+ return best?.id ?? null;
4705
+ }
4706
+ /**
4707
+ * The sites whose roots a command uses as a path.
4708
+ */
4709
+ function sitesOfCommand(command, sites) {
4710
+ return sites.filter((site) => site.roots.some((root) => commandUsesPath(command, root))).map((site) => site.id);
4711
+ }
4712
+ /**
4713
+ * Ownership of a daemon (a Forge background process).
4714
+ *
4715
+ * - With a directory: the site that holds the directory (see {@link siteOfPath}).
4716
+ * - Without a directory: `ambiguous` when the command uses the site root, `site` only when
4717
+ * the command uses no other site root and Forge also associates the daemon with the site.
4718
+ */
4719
+ function daemonOwnership(daemon, siteId, sites, associatedWithSite) {
4720
+ if (daemon.directory && daemon.directory.trim()) return siteOfPath(daemon.directory, sites) === siteId ? "site" : "other";
4721
+ const owners = sitesOfCommand(daemon.command, sites);
4722
+ if (!owners.includes(siteId)) return "other";
4723
+ return owners.length === 1 && associatedWithSite ? "site" : "ambiguous";
4724
+ }
4725
+ /**
4726
+ * Ownership of a scheduled job. Forge lists the jobs of a site on a site route: those
4727
+ * belong to the site. A job of the server belongs to the site when its command uses the
4728
+ * site root and no other site root; when it uses several site roots, it is `ambiguous`.
4729
+ */
4730
+ function scheduledJobOwnership(job, siteId, sites, listedOnSite) {
4731
+ if (listedOnSite) return "site";
4732
+ const owners = sitesOfCommand(job.command, sites);
4733
+ if (!owners.includes(siteId)) return "other";
4734
+ return owners.length === 1 ? "site" : "ambiguous";
4735
+ }
4736
+ //#endregion
4737
+ //#region src/commands/sites/apply/plan.ts
4738
+ /** Shown in place of a generated password. */
4739
+ var GENERATED = "<generated>";
4740
+ /** The path of the site folder, e.g. `/home/forge/example.com`. */
4741
+ function siteHome(site) {
4742
+ return `/home/${site.user}/${site.name}`;
4743
+ }
4744
+ /** The directory that workers run in and relative `artisan` resolves against. */
4745
+ function siteWorkDirectory(site) {
4746
+ return inSiteHome(site, site.root_directory || "/");
4747
+ }
4748
+ /**
4749
+ * Read a directory of a site, absolute (`/home/forge/example.com/public`) or relative to the
4750
+ * site folder (`/public`), as an absolute path inside the site folder. A relative `/` never
4751
+ * becomes the root of the server.
4752
+ */
4753
+ function inSiteHome(site, directory) {
4754
+ const home = siteHome(site);
4755
+ return isPathInside(directory, home) ? normalizePath(directory) : resolvePath(home, directory.replace(/^\/+/, ""));
4756
+ }
4757
+ /** The paths that belong to each site, for ownership. */
4758
+ function siteRoots(sites) {
4759
+ return sites.map((site) => ({
4760
+ id: site.id,
4761
+ roots: [...new Set([siteHome(site), siteWorkDirectory(site)].map(normalizePath))]
4762
+ }));
4763
+ }
4764
+ /** `PHP 8.3`, `php83` and `php8.3` all give `php83`. */
4765
+ function normalizePhp(version) {
4766
+ return version ? version.toLowerCase().replaceAll(/[^a-z0-9]/g, "") : null;
4767
+ }
4768
+ /** Forge displays frequencies in title case, e.g. `Minutely`. */
4769
+ function normalizeFrequency(frequency) {
4770
+ return frequency.trim().toLowerCase();
4771
+ }
4772
+ /** The path a declared site uses before it exists, from its domain. */
4773
+ function plannedWorkDirectory(file) {
4774
+ return `/home/forge/${file.domain}`;
4775
+ }
4776
+ function sameName(a, b) {
4777
+ return a.toLowerCase() === b.toLowerCase();
4778
+ }
4779
+ /**
4780
+ * Pair declared items with existing ones that have the same key, each existing item once.
4781
+ * Returns the declared items without a match and the existing items without a match.
4782
+ */
4783
+ function matchByKey(declared, existing, declaredKey, existingKey) {
4784
+ const pool = existing.map((item) => ({
4785
+ item,
4786
+ key: existingKey(item),
4787
+ used: false
4788
+ }));
4789
+ const missing = [];
4790
+ for (const item of declared) {
4791
+ const key = declaredKey(item);
4792
+ const match = pool.find((entry) => !entry.used && entry.key === key);
4793
+ if (match) match.used = true;
4794
+ else missing.push(item);
4795
+ }
4796
+ return {
4797
+ missing,
4798
+ unmatched: pool.filter((entry) => !entry.used).map((entry) => entry.item)
4799
+ };
4800
+ }
4801
+ function planSite(file, site, changes, warnings) {
4802
+ if (!site) {
4803
+ changes.push({
4804
+ action: "create-site",
4805
+ name: file.domain,
4806
+ settings: {
4807
+ type: file.type,
4808
+ php_version: file.php,
4809
+ web_directory: file.webDirectory,
4810
+ source_control_provider: file.repository?.provider,
4811
+ repository: file.repository?.url,
4812
+ branch: file.repository?.branch
4813
+ }
4814
+ });
4815
+ return;
4816
+ }
4817
+ const updates = [];
4818
+ if (file.php && normalizePhp(site.php_version) !== normalizePhp(file.php)) updates.push({
4819
+ field: "php_version",
4820
+ from: site.php_version,
4821
+ to: file.php
4822
+ });
4823
+ if (file.webDirectory) {
4824
+ const root = siteWorkDirectory(site);
4825
+ const expected = resolvePath(root, file.webDirectory.replace(/^\/+/, ""));
4826
+ if ((isPathInside(site.web_directory, root) ? normalizePath(site.web_directory) : resolvePath(root, site.web_directory.replace(/^\/+/, ""))) !== expected) updates.push({
4827
+ field: "web_directory",
4828
+ from: site.web_directory,
4829
+ to: file.webDirectory
4830
+ });
4831
+ }
4832
+ if (file.repository?.branch && site.repository?.branch !== file.repository.branch) updates.push({
4833
+ field: "branch",
4834
+ from: site.repository?.branch ?? null,
4835
+ to: file.repository.branch
4836
+ });
4837
+ if (updates.length > 0) changes.push({
4838
+ action: "update-site",
4839
+ changes: updates
4840
+ });
4841
+ const url = site.repository?.url;
4842
+ 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.`);
4843
+ 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.`);
4844
+ }
4845
+ function planAliases(file, site, changes) {
4846
+ const existing = site ? [site.name, ...site.aliases ?? []].map(String) : [];
4847
+ for (const alias of new Set(file.aliases ?? [])) if (!existing.some((name) => sameName(name, alias))) changes.push({
4848
+ action: "add-alias",
4849
+ name: alias
4850
+ });
4851
+ }
4852
+ /**
4853
+ * Plan the database and its user. Returns the names of the .env keys that get generated values.
4854
+ */
4855
+ function planDatabase(file, state, changes, warnings) {
4856
+ if (!file.database) return [];
4857
+ const { name, user } = file.database;
4858
+ const databaseExists = (state.databases ?? []).some((db) => db.name === name);
4859
+ const userExists = (state.databaseUsers ?? []).some((u) => u.name === user);
4860
+ if (!databaseExists) changes.push({
4861
+ action: "create-database",
4862
+ name
4863
+ });
4864
+ if (!userExists) {
4865
+ changes.push({
4866
+ action: "create-database-user",
4867
+ name: user,
4868
+ database: name
4869
+ });
4870
+ return [
4871
+ "DB_DATABASE",
4872
+ "DB_USERNAME",
4873
+ "DB_PASSWORD"
4874
+ ];
4875
+ }
4876
+ if (!databaseExists) warnings.push(`Database user ${user} exists: apply does not give it access to the new database ${name}.`);
4877
+ return [];
4878
+ }
4879
+ function planEnv(file, state, generated, changes, warnings) {
4880
+ const remote = parseEnv(state.env ?? "");
4881
+ const keys = [];
4882
+ for (const [key, value] of Object.entries(file.env ?? {})) {
4883
+ if (generated.includes(key)) continue;
4884
+ if (!remote.has(key)) keys.push(key);
4885
+ else if (remote.get(key) !== String(value)) warnings.push(`.env key ${key} has another value on Forge: apply does not overwrite it.`);
4886
+ }
4887
+ if (keys.length > 0 || generated.length > 0) changes.push({
4888
+ action: "add-env",
4889
+ keys,
4890
+ generated
4891
+ });
4892
+ }
4893
+ function describeOwnership(ownership) {
4894
+ return ownership === "ambiguous" ? "its owner is not certain" : "it runs as root";
4895
+ }
4896
+ function planWorkers(file, state, changes, warnings) {
4897
+ if (!file.workers) return;
4898
+ const site = state.site;
4899
+ const workDirectory = site ? siteWorkDirectory(site) : plannedWorkDirectory(file);
4900
+ const declared = file.workers.map((worker) => {
4901
+ const directory = worker.directory ? resolvePath(workDirectory, worker.directory) : workDirectory;
4902
+ return {
4903
+ command: worker.command,
4904
+ directory,
4905
+ processes: worker.processes,
4906
+ key: [
4907
+ canonicalCommand(worker.command, directory),
4908
+ directory,
4909
+ worker.processes,
4910
+ "forge"
4911
+ ].join("\n")
4912
+ };
4913
+ });
4914
+ const roots = siteRoots(state.sites);
4915
+ const { missing, unmatched } = matchByKey(declared, site ? (state.daemons ?? []).flatMap((daemon) => {
4916
+ const ownership = daemonOwnership(daemon, site.id, roots, state.siteDaemonIds?.has(daemon.id) ?? false);
4917
+ return ownership === "other" ? [] : [{
4918
+ daemon,
4919
+ ownership
4920
+ }];
4921
+ }) : [], (worker) => worker.key, ({ daemon }) => {
4922
+ const directory = daemon.directory?.trim() ? normalizePath(daemon.directory) : null;
4923
+ return [
4924
+ canonicalCommand(daemon.command, directory),
4925
+ directory ?? workDirectory,
4926
+ daemon.processes,
4927
+ daemon.user
4928
+ ].join("\n");
4929
+ });
4930
+ for (const worker of missing) changes.push({
4931
+ action: "create-worker",
4932
+ command: worker.command,
4933
+ directory: worker.directory,
4934
+ processes: worker.processes
4935
+ });
4936
+ for (const { daemon, ownership } of unmatched) if (ownership === "site" && daemon.user !== "root") changes.push({
4937
+ action: "delete-worker",
4938
+ id: daemon.id,
4939
+ command: daemon.command,
4940
+ directory: daemon.directory,
4941
+ processes: daemon.processes
4942
+ });
4943
+ else warnings.push(`Kept daemon ${daemon.id} (${daemon.command}): not declared, but ${describeOwnership(ownership)}.`);
4944
+ }
4945
+ function planSchedule(file, state, changes, warnings) {
4946
+ if (!file.schedule) return;
4947
+ const site = state.site;
4948
+ const workDirectory = site ? siteWorkDirectory(site) : plannedWorkDirectory(file);
4949
+ const declared = file.schedule.map((job) => {
4950
+ const command = canonicalCommand(job.command, workDirectory);
4951
+ const frequency = normalizeFrequency(job.frequency);
4952
+ return {
4953
+ command,
4954
+ frequency,
4955
+ cron: job.cron,
4956
+ key: [
4957
+ command,
4958
+ "forge",
4959
+ frequency,
4960
+ frequency === "custom" ? job.cron : ""
4961
+ ].join("\n")
4962
+ };
4963
+ });
4964
+ const roots = siteRoots(state.sites);
4965
+ const seen = /* @__PURE__ */ new Set();
4966
+ const { missing, unmatched } = matchByKey(declared, site ? (state.jobs ?? []).flatMap((job) => {
4967
+ if (seen.has(job.id)) return [];
4968
+ seen.add(job.id);
4969
+ const onSite = state.siteJobIds?.has(job.id) ?? false;
4970
+ const ownership = scheduledJobOwnership(job, site.id, roots, onSite);
4971
+ return ownership === "other" ? [] : [{
4972
+ job,
4973
+ ownership,
4974
+ onSite
4975
+ }];
4976
+ }) : [], (job) => job.key, ({ job }) => {
4977
+ const frequency = normalizeFrequency(job.frequency);
4978
+ return [
4979
+ canonicalCommand(job.command, workDirectory),
4980
+ job.user,
4981
+ frequency,
4982
+ frequency === "custom" ? job.cron : ""
4983
+ ].join("\n");
4984
+ });
4985
+ for (const job of missing) changes.push({
4986
+ action: "create-job",
4987
+ command: job.command,
4988
+ frequency: job.frequency,
4989
+ ...job.frequency === "custom" ? { cron: job.cron } : {}
4990
+ });
4991
+ for (const { job, ownership, onSite } of unmatched) if (ownership === "site" && job.user !== "root") changes.push({
4992
+ action: "delete-job",
4993
+ id: job.id,
4994
+ command: job.command,
4995
+ frequency: job.frequency,
4996
+ cron: job.cron,
4997
+ onSite
4998
+ });
4999
+ else warnings.push(`Kept scheduled job ${job.id} (${job.command}): not declared, but ${describeOwnership(ownership)}.`);
5000
+ }
5001
+ /**
5002
+ * Compare the site file with the state of Forge. Keys that the file does not have are not
5003
+ * compared. The plan creates and updates what differs, and deletes only the workers and
5004
+ * scheduled jobs that surely belong to the site and that the file does not declare.
5005
+ */
5006
+ function buildPlan(file, state) {
5007
+ const changes = [];
5008
+ const warnings = [];
5009
+ planSite(file, state.site, changes, warnings);
5010
+ planAliases(file, state.site, changes);
5011
+ const generated = planDatabase(file, state, changes, warnings);
5012
+ if (file.env || generated.length > 0) planEnv(file, state, generated, changes, warnings);
5013
+ planWorkers(file, state, changes, warnings);
5014
+ planSchedule(file, state, changes, warnings);
5015
+ return {
5016
+ changes,
5017
+ warnings
5018
+ };
5019
+ }
5020
+ function quoted(value) {
5021
+ return value ?? "(none)";
5022
+ }
5023
+ /**
5024
+ * One line per change, for human output. Values of .env keys never show.
5025
+ */
5026
+ function describeChange(change) {
5027
+ switch (change.action) {
5028
+ case "create-site": {
5029
+ const settings = Object.entries(change.settings).filter(([, value]) => value !== void 0).map(([key, value]) => `${key}: ${value}`);
5030
+ return `+ create site ${change.name}${settings.length ? ` (${settings.join(", ")})` : ""}`;
5031
+ }
5032
+ case "update-site": return `~ update site: ${change.changes.map((c) => `${c.field} ${quoted(c.from)} → ${c.to}`).join(", ")}`;
5033
+ case "add-alias": return `+ add alias ${change.name}`;
5034
+ case "create-database": return `+ create database ${change.name}`;
5035
+ case "create-database-user": return `+ create database user ${change.name} for ${change.database} (password: ${GENERATED})`;
5036
+ case "add-env": return `+ add .env keys: ${[...change.keys, ...change.generated.map((key) => key === "DB_PASSWORD" ? `${key}=${GENERATED}` : key)].join(", ")}`;
5037
+ case "create-worker": return `+ create worker: ${change.command} (directory: ${change.directory}, processes: ${change.processes})`;
5038
+ case "delete-worker": return `- delete worker ${change.id}: ${change.command} (directory: ${quoted(change.directory)}, processes: ${change.processes})`;
5039
+ case "create-job": return `+ create scheduled job: ${change.command} (${change.frequency}${change.cron ? ` ${change.cron}` : ""})`;
5040
+ case "delete-job": return `- delete scheduled job ${change.id}: ${change.command} (${change.frequency.toLowerCase()} ${change.cron})`;
5041
+ }
5042
+ }
5043
+ //#endregion
5044
+ //#region src/commands/sites/apply/apply.ts
5045
+ /**
5046
+ * Read the state of Forge for a site file, and run a plan against Forge.
5047
+ */
5048
+ /** Error of `apply` that is not a validation error, e.g. a site that never gets installed. */
5049
+ var ApplyError = class extends CliError {
5050
+ code = "APPLY_ERROR";
5051
+ isRecoverable = false;
5052
+ };
5053
+ /**
5054
+ * Resolve the server of the site file, always with an exact name (never a partial match).
5055
+ */
5056
+ async function resolveApplyServer(server, ctx) {
5057
+ const value = String(server);
5058
+ if (/^\d+$/.test(value)) return value;
5059
+ return matchServerId(value, (await listServers({}, ctx)).data, true);
5060
+ }
5061
+ /**
5062
+ * The site named `domain` on the server, compared without case. A partial match never counts.
5063
+ */
5064
+ function findSite(sites, domain) {
5065
+ return sites.find((site) => site.name.toLowerCase() === domain.toLowerCase()) ?? null;
5066
+ }
5067
+ /**
5068
+ * Read from Forge what the site file manages, and nothing else.
5069
+ */
5070
+ async function readRemoteState(file, serverId, ctx) {
5071
+ const sites = (await listSites({ server_id: serverId }, ctx)).data;
5072
+ const site = findSite(sites, file.domain);
5073
+ const siteId = site ? String(site.id) : void 0;
5074
+ const state = {
5075
+ sites,
5076
+ site
5077
+ };
5078
+ if (file.database) {
5079
+ state.databases = (await listDatabases({ server_id: serverId }, ctx)).data;
5080
+ state.databaseUsers = (await listDatabaseUsers({ server_id: serverId }, ctx)).data;
5081
+ }
5082
+ if (file.env || file.database) state.env = siteId ? (await getEnv({
5083
+ server_id: serverId,
5084
+ site_id: siteId
5085
+ }, ctx)).data : null;
5086
+ if (file.workers) {
5087
+ state.daemons = (await listDaemons({ server_id: serverId }, ctx)).data;
5088
+ state.siteDaemonIds = new Set(siteId ? (await listDaemons({
5089
+ server_id: serverId,
5090
+ site_id: siteId
5091
+ }, ctx)).data.map((d) => d.id) : []);
5092
+ }
5093
+ if (file.schedule) {
5094
+ const siteJobs = siteId ? (await listScheduledJobs({
5095
+ server_id: serverId,
5096
+ site_id: siteId
5097
+ }, ctx)).data : [];
5098
+ const serverJobs = (await listScheduledJobs({ server_id: serverId }, ctx)).data;
5099
+ state.jobs = [...siteJobs, ...serverJobs];
5100
+ state.siteJobIds = new Set(siteJobs.map((job) => job.id));
5101
+ }
5102
+ return state;
5103
+ }
5104
+ /**
5105
+ * Exit with a validation error when the site does not exist and the site file cannot create it.
5106
+ */
5107
+ function assertCanCreateSite(file, state) {
5108
+ 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"]);
5109
+ }
5110
+ /** A random password for a database user: 32 characters, letters and digits only. */
5111
+ function generatePassword() {
5112
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
5113
+ let password = "";
5114
+ while (password.length < 32) for (const byte of randomBytes(64)) if (byte < 248 && password.length < 32) password += alphabet[byte % 62];
5115
+ return password;
5116
+ }
5117
+ /** Write a value for a .env file, quoted when it needs quotes. */
5118
+ function formatEnvValue(value) {
5119
+ if (/^[\w.,:/@+-]*$/.test(value)) return value;
5120
+ if (!value.includes("'") && !value.includes("\n")) return `'${value}'`;
5121
+ return `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"").replaceAll("\n", "\\n")}"`;
5122
+ }
5123
+ /**
5124
+ * Set .env variables: replace the line of a variable that exists, append the others.
5125
+ */
5126
+ function setEnvValues(content, values) {
5127
+ const lines = content === "" ? [] : content.replace(/\n$/, "").split("\n");
5128
+ const pending = new Map(values);
5129
+ const result = lines.map((line) => {
5130
+ const name = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.]*)\s*=/.exec(line)?.[1];
5131
+ if (name && pending.has(name)) {
5132
+ const value = pending.get(name);
5133
+ pending.delete(name);
5134
+ return `${name}=${formatEnvValue(value)}`;
5135
+ }
5136
+ return line;
5137
+ });
5138
+ for (const [name, value] of pending) result.push(`${name}=${formatEnvValue(value)}`);
5139
+ return `${result.join("\n")}\n`;
5140
+ }
5141
+ var defaultSleep = (ms) => new Promise((resolve) => {
5142
+ setTimeout(resolve, ms);
5143
+ });
5144
+ async function waitForSite(serverId, siteId, ctx, options) {
5145
+ const sleep = options.sleep ?? defaultSleep;
5146
+ const interval = options.pollIntervalMs ?? 5e3;
5147
+ const timeout = options.timeoutMs ?? 6e5;
5148
+ for (let waited = 0;; waited += interval) {
5149
+ const site = (await getSite({
5150
+ server_id: serverId,
5151
+ site_id: siteId
5152
+ }, ctx)).data;
5153
+ 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"]);
5155
+ await sleep(interval);
5156
+ }
5157
+ }
5158
+ /**
5159
+ * Create the database and its user. Returns their IDs and the .env values to write.
5160
+ */
5161
+ async function applyDatabase(file, plan, state, serverId, ctx, options) {
5162
+ const env = /* @__PURE__ */ new Map();
5163
+ const ids = {};
5164
+ if (!file.database) return {
5165
+ ids,
5166
+ env
5167
+ };
5168
+ let databaseId = state.databases?.find((db) => db.name === file.database.name)?.id;
5169
+ for (const change of plan.changes) if (change.action === "create-database") {
5170
+ options.onChange?.(change);
5171
+ databaseId = (await createDatabase({
5172
+ server_id: serverId,
5173
+ name: change.name
5174
+ }, ctx)).data.id;
5175
+ } else if (change.action === "create-database-user") {
5176
+ options.onChange?.(change);
5177
+ const password = (options.generatePassword ?? generatePassword)();
5178
+ const user = await createDatabaseUser({
5179
+ server_id: serverId,
5180
+ name: change.name,
5181
+ password,
5182
+ ...databaseId === void 0 ? {} : { database_ids: [databaseId] }
5183
+ }, ctx);
5184
+ ids.database_user_id = String(user.data.id);
5185
+ env.set("DB_DATABASE", change.database);
5186
+ env.set("DB_USERNAME", change.name);
5187
+ env.set("DB_PASSWORD", password);
5188
+ }
5189
+ if (databaseId !== void 0) ids.database_id = databaseId;
5190
+ return {
5191
+ ids,
5192
+ env
5193
+ };
5194
+ }
5195
+ /**
5196
+ * Run the changes of a plan for a site that exists.
5197
+ */
5198
+ async function applyChanges(file, plan, state, serverId, site, generatedEnv, ctx, options) {
5199
+ const params = {
5200
+ server_id: serverId,
5201
+ site_id: String(site.id)
5202
+ };
5203
+ const { env: databaseEnv } = await applyDatabase(file, plan, state, serverId, ctx, options);
5204
+ const env = new Map([...generatedEnv, ...databaseEnv]);
5205
+ const creations = [];
5206
+ const deletions = [];
5207
+ for (const change of plan.changes) switch (change.action) {
5208
+ case "update-site": {
5209
+ options.onChange?.(change);
5210
+ const update = {};
5211
+ for (const { field, to } of change.changes) {
5212
+ if (field === "php_version") update.php_version = to;
5213
+ if (field === "web_directory") update.directory = to;
5214
+ if (field === "branch") update.repository_branch = to;
5215
+ }
5216
+ await updateSite({
5217
+ ...params,
5218
+ ...update
5219
+ }, ctx);
5220
+ break;
5221
+ }
5222
+ case "add-alias":
5223
+ options.onChange?.(change);
5224
+ await createDomain({
5225
+ ...params,
5226
+ name: change.name,
5227
+ allow_wildcard_subdomains: false,
5228
+ www_redirect_type: "none"
5229
+ }, ctx);
5230
+ break;
5231
+ case "add-env":
5232
+ for (const key of change.keys) env.set(key, String(file.env?.[key]));
5233
+ break;
5234
+ case "create-worker":
5235
+ case "create-job":
5236
+ creations.push(change);
5237
+ break;
5238
+ case "delete-worker":
5239
+ case "delete-job":
5240
+ deletions.push(change);
5241
+ break;
5242
+ default: break;
5243
+ }
5244
+ if (env.size > 0) {
5245
+ const addEnv = plan.changes.find((change) => change.action === "add-env");
5246
+ if (addEnv) options.onChange?.(addEnv);
5247
+ const content = (await getEnv(params, ctx)).data;
5248
+ await updateEnv({
5249
+ ...params,
5250
+ content: setEnvValues(content, env)
5251
+ }, ctx);
5252
+ }
5253
+ for (const change of creations) {
5254
+ options.onChange?.(change);
5255
+ if (change.action === "create-worker") await createDaemon({
5256
+ server_id: serverId,
5257
+ site_id: site.id,
5258
+ name: change.command.slice(0, 255),
5259
+ command: change.command,
5260
+ user: "forge",
5261
+ directory: change.directory,
5262
+ processes: change.processes
5263
+ }, ctx);
5264
+ else if (change.action === "create-job") await createScheduledJob({
5265
+ ...params,
5266
+ command: change.command,
5267
+ user: "forge",
5268
+ frequency: change.frequency,
5269
+ ...change.cron ? { cron: change.cron } : {}
5270
+ }, ctx);
5271
+ }
5272
+ for (const change of deletions) {
5273
+ options.onChange?.(change);
5274
+ if (change.action === "delete-worker") await deleteDaemon({
5275
+ server_id: serverId,
5276
+ id: String(change.id)
5277
+ }, ctx);
5278
+ else if (change.action === "delete-job") await deleteScheduledJob({
5279
+ server_id: serverId,
5280
+ id: String(change.id),
5281
+ ...change.onSite ? { site_id: params.site_id } : {}
5282
+ }, ctx);
5283
+ }
5284
+ }
5285
+ /**
5286
+ * Apply a site file: create the site when it does not exist and wait until Forge installs it,
5287
+ * then read the state again and run the other changes. Returns the plan that ran first.
5288
+ */
5289
+ async function applySiteFile(file, serverId, ctx, options = {}) {
5290
+ let state = await readRemoteState(file, serverId, ctx);
5291
+ const firstPlan = buildPlan(file, state);
5292
+ let plan = firstPlan;
5293
+ let generatedEnv = /* @__PURE__ */ new Map();
5294
+ const createSiteChange = plan.changes.find((change) => change.action === "create-site");
5295
+ if (createSiteChange && createSiteChange.action === "create-site") {
5296
+ assertCanCreateSite(file, state);
5297
+ const database = await applyDatabase(file, plan, state, serverId, ctx, options);
5298
+ generatedEnv = database.env;
5299
+ options.onChange?.(createSiteChange);
5300
+ const { settings } = createSiteChange;
5301
+ const created = await createSite({
5302
+ server_id: serverId,
5303
+ type: file.type,
5304
+ name: file.domain,
5305
+ ...settings.php_version ? { php_version: settings.php_version } : {},
5306
+ ...settings.web_directory ? { web_directory: settings.web_directory } : {},
5307
+ ...settings.source_control_provider ? { source_control_provider: settings.source_control_provider } : {},
5308
+ ...settings.repository ? { repository: settings.repository } : {},
5309
+ ...settings.branch ? { branch: settings.branch } : {},
5310
+ ...database.ids
5311
+ }, ctx);
5312
+ await waitForSite(serverId, String(created.data.id), ctx, options);
5313
+ state = await readRemoteState(file, serverId, ctx);
5314
+ plan = buildPlan(file, state);
5315
+ }
5316
+ const site = state.site;
5317
+ if (!site) throw new ApplyError(`Site ${file.domain} is not on server ${serverId} after its creation`);
5318
+ await applyChanges(file, plan, state, serverId, site, generatedEnv, ctx, options);
5319
+ return firstPlan;
5320
+ }
5321
+ //#endregion
5322
+ //#region src/commands/sites/apply/site-file.ts
5323
+ /**
5324
+ * The site file of `forge sites apply`: a JSON file that declares one Forge site.
5325
+ * A key that is absent is not managed; only `server` and `domain` are required.
5326
+ */
5327
+ var text = v.pipe(v.string(), v.trim(), v.nonEmpty());
5328
+ var SITE_TYPES = [
5329
+ "laravel",
5330
+ "symfony",
5331
+ "statamic",
5332
+ "wordpress",
5333
+ "phpmyadmin",
5334
+ "php",
5335
+ "nextjs",
5336
+ "nuxtjs",
5337
+ "static-html",
5338
+ "other",
5339
+ "custom"
5340
+ ];
5341
+ var SOURCE_CONTROL_PROVIDERS = [
5342
+ "github",
5343
+ "gitlab",
5344
+ "bitbucket",
5345
+ "gitlab-custom",
5346
+ "custom"
5347
+ ];
5348
+ var SCHEDULE_FREQUENCIES = [
5349
+ "minutely",
5350
+ "hourly",
5351
+ "nightly",
5352
+ "weekly",
5353
+ "monthly",
5354
+ "reboot",
5355
+ "custom"
5356
+ ];
5357
+ var WorkerSchema = v.strictObject({
5358
+ command: text,
5359
+ processes: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 1),
5360
+ directory: v.optional(text)
5361
+ });
5362
+ var ScheduledJobSchema = v.pipe(v.strictObject({
5363
+ command: text,
5364
+ frequency: v.picklist(SCHEDULE_FREQUENCIES),
5365
+ cron: v.optional(text)
5366
+ }), v.check((job) => job.frequency === "custom" === (job.cron !== void 0), "cron is required with the custom frequency, and only with it"));
5367
+ var SiteFileSchema = v.strictObject({
5368
+ $schema: v.optional(v.string()),
5369
+ server: v.union([text, v.pipe(v.number(), v.integer(), v.minValue(1))]),
5370
+ domain: text,
5371
+ aliases: v.optional(v.array(text)),
5372
+ type: v.optional(v.picklist(SITE_TYPES)),
5373
+ php: v.optional(v.pipe(v.string(), v.regex(/^php\d+$/, "php must look like php83"))),
5374
+ webDirectory: v.optional(text),
5375
+ repository: v.optional(v.strictObject({
5376
+ provider: v.picklist(SOURCE_CONTROL_PROVIDERS),
5377
+ url: text,
5378
+ branch: v.optional(text)
5379
+ })),
5380
+ database: v.optional(v.strictObject({
5381
+ name: text,
5382
+ user: text
5383
+ })),
5384
+ env: v.optional(v.record(v.pipe(v.string(), v.regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "invalid .env variable name")), v.union([
5385
+ v.string(),
5386
+ v.number(),
5387
+ v.boolean()
5388
+ ]))),
5389
+ workers: v.optional(v.array(WorkerSchema)),
5390
+ schedule: v.optional(v.array(ScheduledJobSchema))
5391
+ });
5392
+ /**
5393
+ * Validate parsed JSON as a site file. Throws a ValidationError that lists every issue.
5394
+ */
5395
+ function parseSiteFile(data, source = "site file") {
5396
+ const result = v.safeParse(SiteFileSchema, data);
5397
+ if (!result.success) {
5398
+ const issues = result.issues.map((issue) => {
5399
+ return `${v.getDotPath(issue) ?? "(root)"}: ${issue.message}`;
5400
+ });
5401
+ throw new ValidationError(`Invalid ${source}`, "file", issues);
5402
+ }
5403
+ return result.output;
5404
+ }
5405
+ /**
5406
+ * Read and validate a site file from disk.
5407
+ */
5408
+ function readSiteFile(path) {
5409
+ let content;
5410
+ try {
5411
+ content = readFileSync(path, "utf8");
5412
+ } catch (error) {
5413
+ throw new ValidationError(`Cannot read site file ${path}: ${error instanceof Error ? error.message : String(error)}`, "file");
5414
+ }
5415
+ let data;
5416
+ try {
5417
+ data = JSON.parse(content);
5418
+ } catch (error) {
5419
+ throw new ValidationError(`Site file ${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, "file");
5420
+ }
5421
+ return parseSiteFile(data, `site file ${path}`);
5422
+ }
5423
+ //#endregion
5424
+ //#region src/commands/sites/apply/handler.ts
5425
+ /**
5426
+ * `forge sites apply --file site.json [--check]`: make a Forge site match a site file.
5427
+ * With `--check`, print the plan and exit 1 when Forge differs, without any change.
5428
+ */
5429
+ async function sitesApply(ctx, applyOptions = {}) {
5430
+ const usage = "forge sites apply --file <site.json> [--check]";
5431
+ const path = ctx.options.file;
5432
+ const check = Boolean(ctx.options.check);
5433
+ if (typeof path !== "string" || !path) exitWithValidationError("file", usage, ctx.formatter);
5434
+ await runCommand(async () => {
5435
+ const file = readSiteFile(path);
5436
+ const token = ctx.getToken();
5437
+ const execCtx = ctx.createExecutorContext(token);
5438
+ const server_id = await resolveApplyServer(file.server, execCtx);
5439
+ if (check) {
5440
+ const state = await readRemoteState(file, server_id, execCtx);
5441
+ assertCanCreateSite(file, state);
5442
+ const plan = buildPlan(file, state);
5443
+ const inSync = plan.changes.length === 0;
5444
+ if (ctx.formatter.isJson()) ctx.formatter.output({
5445
+ inSync,
5446
+ changes: plan.changes,
5447
+ warnings: plan.warnings
5448
+ });
5449
+ else {
5450
+ for (const change of plan.changes) console.log(describeChange(change));
5451
+ for (const warning of plan.warnings) ctx.formatter.warning(warning);
5452
+ if (inSync) ctx.formatter.success(`Site ${file.domain} is in sync with Forge.`);
5453
+ else ctx.formatter.error(`Site ${file.domain} differs from Forge.`);
5454
+ }
5455
+ if (!inSync) process.exitCode = ExitCode.GENERAL_ERROR;
5456
+ return;
5457
+ }
5458
+ const applied = [];
5459
+ const plan = await applySiteFile(file, server_id, execCtx, {
5460
+ ...applyOptions,
5461
+ onChange: (change) => {
5462
+ applied.push(change.action);
5463
+ if (!ctx.formatter.isJson()) console.log(describeChange(change));
5464
+ }
5465
+ });
5466
+ if (ctx.formatter.isJson()) ctx.formatter.output({
5467
+ changes: plan.changes,
5468
+ warnings: plan.warnings
5469
+ });
5470
+ else {
5471
+ for (const warning of plan.warnings) ctx.formatter.warning(warning);
5472
+ ctx.formatter.success(applied.length === 0 ? `Site ${file.domain} is in sync with Forge.` : `Site ${file.domain} applied: ${applied.length} change(s).`);
5473
+ }
5474
+ }, ctx.formatter);
5475
+ }
5476
+ //#endregion
4579
5477
  //#region src/commands/sites/handlers.ts
4580
5478
  async function sitesList(ctx) {
4581
5479
  const server = String(ctx.options.server ?? "");
@@ -4660,8 +5558,10 @@ var handleSitesCommand = createCommandRouter({
4660
5558
  list: sitesList,
4661
5559
  ls: sitesList,
4662
5560
  get: [sitesGet, "args"],
4663
- update: [sitesUpdate, "args"]
4664
- }
5561
+ update: [sitesUpdate, "args"],
5562
+ apply: (ctx) => sitesApply(ctx)
5563
+ },
5564
+ writeSubcommands: ["apply"]
4665
5565
  });
4666
5566
  //#endregion
4667
5567
  //#region src/commands/sites/help.ts
@@ -4692,6 +5592,38 @@ ${colors.bold("ARGUMENTS:")}
4692
5592
  ${colors.bold("OPTIONS:")}
4693
5593
  --server <id> Server ID (required)
4694
5594
  -f, --format <fmt> Output format: json, human, table
5595
+ `);
5596
+ else if (subcommand === "apply") console.log(`
5597
+ ${colors.bold("forge sites apply")} - Make a site match a site file
5598
+
5599
+ ${colors.bold("USAGE:")}
5600
+ forge sites apply --file <site.json> [--check] [options]
5601
+
5602
+ ${colors.bold("OPTIONS:")}
5603
+ --file <path> Site file (required), e.g. config/forge/production/site.json
5604
+ --check Print the plan and change nothing: exit 0 when Forge matches,
5605
+ exit 1 when it differs, another code on error
5606
+ -f, --format <fmt> Output format: json, human
5607
+
5608
+ ${colors.bold("SITE FILE:")}
5609
+ Only "server" (exact name or ID) and "domain" (exact site name) are required.
5610
+ A key that is absent is not managed: apply does not read or change it.
5611
+ type, php, webDirectory, repository Create the site, or update php, web directory, branch
5612
+ aliases Add missing aliases, never remove one
5613
+ database { name, user } Create when missing; a new user gets a random
5614
+ password, written into the site .env only
5615
+ env Add missing keys, never overwrite a value
5616
+ workers, schedule Create missing ones, delete the ones of the site
5617
+ that the file does not declare ([] deletes all)
5618
+
5619
+ A worker or scheduled job is deleted only when it surely belongs to the site:
5620
+ Forge lists it on the site, or its directory (or, without a directory, its command)
5621
+ uses the site path, compared on whole path segments. Jobs of root are never deleted.
5622
+ The plan never prints .env values.
5623
+
5624
+ ${colors.bold("EXAMPLES:")}
5625
+ forge sites apply --file config/forge/production/site.json --check
5626
+ forge sites apply --file config/forge/production/site.json
4695
5627
  `);
4696
5628
  else console.log(`
4697
5629
  ${colors.bold("forge sites")} - Manage sites
@@ -4702,6 +5634,7 @@ ${colors.bold("USAGE:")}
4702
5634
  ${colors.bold("SUBCOMMANDS:")}
4703
5635
  list, ls List sites on a server
4704
5636
  get <id> Get site details
5637
+ apply Make a site match a site file (--file, --check)
4705
5638
 
4706
5639
  ${colors.bold("OPTIONS:")}
4707
5640
  --server <id> Server ID (required for most commands)
@@ -4711,6 +5644,7 @@ ${colors.bold("OPTIONS:")}
4711
5644
  ${colors.bold("EXAMPLES:")}
4712
5645
  forge sites list --server 123
4713
5646
  forge sites get 456 --server 123
5647
+ forge sites apply --file config/forge/production/site.json --check
4714
5648
 
4715
5649
  Run ${colors.cyan("forge sites <subcommand> --help")} for subcommand details.
4716
5650
  `);
@@ -5042,7 +5976,7 @@ async function resolveTargets(values, allServers, execCtx) {
5042
5976
  }));
5043
5977
  const targets = /* @__PURE__ */ new Map();
5044
5978
  for (const value of values) {
5045
- const id = Number(matchServerId(value, servers));
5979
+ const id = Number(matchServerId(value, servers, execCtx.exactMatch));
5046
5980
  targets.set(id, servers.find((s) => s.id === id)?.name ?? String(id));
5047
5981
  }
5048
5982
  return [...targets].map(([id, name]) => ({
@@ -5502,6 +6436,11 @@ var REPEATABLE = /* @__PURE__ */ new Set([
5502
6436
  "databases"
5503
6437
  ]);
5504
6438
  /**
6439
+ * Long options that never take a value, so the argument after them stays a command
6440
+ * or a positional argument (`forge --exact deployments deploy`).
6441
+ */
6442
+ var FLAGS = /* @__PURE__ */ new Set(["exact"]);
6443
+ /**
5505
6444
  * Set an option value. A repeatable option given more than once collects its string
5506
6445
  * values into an array (`--server 1 --server 2` → `["1", "2"]`).
5507
6446
  */
@@ -5522,7 +6461,7 @@ function parseArgs(argv = process.argv.slice(2)) {
5522
6461
  else {
5523
6462
  const key = arg.slice(2);
5524
6463
  const nextArg = argv[i + 1];
5525
- if (nextArg !== void 0 && isOptionValue(nextArg)) {
6464
+ if (!FLAGS.has(key) && nextArg !== void 0 && isOptionValue(nextArg)) {
5526
6465
  setOption(options, key, nextArg);
5527
6466
  i++;
5528
6467
  } else setOption(options, key, true);
@@ -5554,7 +6493,7 @@ function isVersionRequest({ command, options }) {
5554
6493
  }
5555
6494
  //#endregion
5556
6495
  //#region src/cli.ts
5557
- var VERSION = "0.5.0";
6496
+ var VERSION = "0.5.2";
5558
6497
  function showHelp() {
5559
6498
  console.log(`
5560
6499
  ${colors.bold("forge")} v${VERSION}
@@ -5577,6 +6516,7 @@ ${colors.bold("COMMANDS:")}
5577
6516
  sites Manage sites
5578
6517
  list, ls List sites (requires --server)
5579
6518
  get <id> Get site details (requires --server)
6519
+ apply Make a site match a site file (--file, --check)
5580
6520
 
5581
6521
  deployments, d Manage deployments
5582
6522
  list, ls List deployments (requires --server --site)
@@ -5710,6 +6650,7 @@ ${colors.bold("OPTIONS:")}
5710
6650
  --org <slug> Organization slug (overrides config and FORGE_ORG)
5711
6651
  --server <id> Server ID (required for server-scoped commands)
5712
6652
  --site <id> Site ID (required for site-scoped commands)
6653
+ --exact Match --server and --site names exactly (no partial match)
5713
6654
  --domain <id> Domain record ID (for certificate commands)
5714
6655
  -f, --format <fmt> Output format: json, human, table (default: human)
5715
6656
  --no-color Disable colored output
@@ -5743,6 +6684,7 @@ ${colors.bold("CREDENTIAL PRIORITY:")}
5743
6684
  ${colors.bold("ENVIRONMENT VARIABLES:")}
5744
6685
  FORGE_API_TOKEN API token
5745
6686
  FORGE_ORG Organization slug (e.g. studio-meta)
6687
+ FORGE_EXACT_MATCH Set to 1 to match --server and --site names exactly (same as --exact)
5746
6688
  NO_COLOR Disable colors
5747
6689
  XDG_CONFIG_HOME Config directory (respects XDG spec)
5748
6690
  `);