@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/README.md +70 -1
- package/dist/cli.js +985 -43
- package/dist/cli.js.map +1 -1
- package/dist/commands/completion.d.ts.map +1 -1
- package/dist/commands/sites/apply/apply.d.ts +54 -0
- package/dist/commands/sites/apply/apply.d.ts.map +1 -0
- package/dist/commands/sites/apply/handler.d.ts +8 -0
- package/dist/commands/sites/apply/handler.d.ts.map +1 -0
- package/dist/commands/sites/apply/ownership.d.ts +88 -0
- package/dist/commands/sites/apply/ownership.d.ts.map +1 -0
- package/dist/commands/sites/apply/plan.d.ts +128 -0
- package/dist/commands/sites/apply/plan.d.ts.map +1 -0
- package/dist/commands/sites/apply/site-file.d.ts +69 -0
- package/dist/commands/sites/apply/site-file.d.ts.map +1 -0
- package/dist/commands/sites/apply/test-fixtures.d.ts +74 -0
- package/dist/commands/sites/apply/test-fixtures.d.ts.map +1 -0
- package/dist/commands/sites/command.d.ts.map +1 -1
- package/dist/commands/sites/help.d.ts.map +1 -1
- package/dist/commands/ssh-keys/sync.d.ts.map +1 -1
- package/dist/context.d.ts +15 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/{error-handler-Adi84RcF.js → error-handler-s-BOJ1-h.js} +16 -4
- package/dist/error-handler-s-BOJ1-h.js.map +1 -0
- package/dist/index.js +1 -1
- package/dist/utils/args.d.ts.map +1 -1
- package/dist/utils/resolve.d.ts +19 -13
- package/dist/utils/resolve.d.ts.map +1 -1
- package/package.json +6 -4
- package/schemas/site.schema.json +122 -0
- 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-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
107
|
-
*
|
|
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
|
-
|
|
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.
|
|
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
|
`);
|