@studiometa/forge-cli 0.4.6 → 0.4.7
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 +68 -6
- package/dist/cli.js +1180 -51
- package/dist/cli.js.map +1 -1
- package/dist/commands/completion.d.ts.map +1 -1
- package/dist/commands/deployments/command.d.ts.map +1 -1
- package/dist/commands/deployments/handlers.d.ts +2 -0
- package/dist/commands/deployments/handlers.d.ts.map +1 -1
- package/dist/commands/deployments/help.d.ts.map +1 -1
- package/dist/commands/env/env-diff.d.ts +29 -0
- package/dist/commands/env/env-diff.d.ts.map +1 -0
- package/dist/commands/env/handlers.d.ts.map +1 -1
- package/dist/commands/env/help.d.ts.map +1 -1
- package/dist/commands/nginx/handlers.d.ts.map +1 -1
- package/dist/commands/nginx/help.d.ts.map +1 -1
- package/dist/commands/php/command.d.ts +2 -0
- package/dist/commands/php/command.d.ts.map +1 -0
- package/dist/commands/php/handlers.d.ts +42 -0
- package/dist/commands/php/handlers.d.ts.map +1 -0
- package/dist/commands/php/help.d.ts +2 -0
- package/dist/commands/php/help.d.ts.map +1 -0
- package/dist/commands/php/index.d.ts +3 -0
- package/dist/commands/php/index.d.ts.map +1 -0
- package/dist/commands/services/handlers.d.ts.map +1 -1
- package/dist/commands/ssh-keys/command.d.ts.map +1 -1
- package/dist/commands/ssh-keys/help.d.ts.map +1 -1
- package/dist/commands/ssh-keys/sync-plan.d.ts +56 -0
- package/dist/commands/ssh-keys/sync-plan.d.ts.map +1 -0
- package/dist/commands/ssh-keys/sync.d.ts +6 -0
- package/dist/commands/ssh-keys/sync.d.ts.map +1 -0
- package/dist/{error-handler-BA357jIr.js → error-handler-Adi84RcF.js} +10 -5
- package/dist/{error-handler-BA357jIr.js.map → error-handler-Adi84RcF.js.map} +1 -1
- package/dist/error-handler.d.ts +5 -0
- package/dist/error-handler.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/utils/args.d.ts +6 -0
- package/dist/utils/args.d.ts.map +1 -1
- package/dist/utils/content-source.d.ts +24 -0
- package/dist/utils/content-source.d.ts.map +1 -0
- package/dist/utils/drift.d.ts +33 -0
- package/dist/utils/drift.d.ts.map +1 -0
- package/dist/utils/resolve.d.ts +8 -0
- package/dist/utils/resolve.d.ts.map +1 -1
- package/package.json +3 -3
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import { RESTARTABLE_SERVICES, activateCertificate, createAuditLogger, createBackupConfig, createCommand, createDatabaseUser, createMonitor, createNginxTemplate, createRedirectRule, createScheduledJob, createSecurityRule, deleteBackupConfig, deleteDatabaseUser, deleteMonitor, deleteNginxTemplate, deleteRedirectRule, deleteScheduledJob, deleteSecurityRule, deploySiteAndWait, getBackupConfig, getCertificate, getCommand, getDaemon, getDaemonLog, getDatabase, getDatabaseUser, getDeploymentLog, getEnv, getFirewallRule, getMonitor, getNginxConfig, getNginxTemplate, getRecipe, getRedirectRule, getScheduledJob, getSecurityRule, getServer, getSite, getSshKey, getUser, listBackupConfigs, listCommands, listDaemons, listDatabaseUsers, listDatabases, listDeployments, listFirewallRules, listMonitors, listNginxTemplates, listRecipes, listRedirectRules, listScheduledJobs, listSecurityRules, listServers, listServices, listSites, listSshKeys, matchByName, rebootServer, restartDaemon, restartService, runRecipe, updateBackupConfig, updateDaemon, updateDatabaseUser, updateEnv, updateNginxConfig, updateNginxTemplate, updateRecipe, updateSecurityRule, updateSite } from "@studiometa/forge-core";
|
|
4
|
-
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { C as isOutputFormat, _ as setToken, a as runCommand, c as OutputFormatter, d as createConfigStore, f as deleteToken, g as setOrganizationSlug, h as getToken, 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";
|
|
3
|
+
import { PHP_CONFIG_TYPES, RESTARTABLE_SERVICES, activateCertificate, createAuditLogger, createBackupConfig, createCommand, createDatabaseUser, createMonitor, createNginxTemplate, createRedirectRule, createScheduledJob, createSecurityRule, createSshKey, deleteBackupConfig, deleteDatabaseUser, deleteMonitor, deleteNginxTemplate, deleteRedirectRule, deleteScheduledJob, deleteSecurityRule, deleteSshKey, 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, getUser, listBackupConfigs, listCommands, listDaemons, listDatabaseUsers, listDatabases, listDeployments, listFirewallRules, listMonitors, listNginxTemplates, listPhpVersions, listRecipes, listRedirectRules, listScheduledJobs, listSecurityRules, listServers, listServices, listSites, listSshKeys, matchByName, rebootServer, restartDaemon, restartService, runRecipe, updateBackupConfig, updateDaemon, updateDatabaseUser, updateDeploymentScript, updateEnv, updateNginxConfig, updateNginxTemplate, updatePhpConfig, updatePhpMaxExecutionTime, updatePhpMaxUploadSize, updateRecipe, updateSecurityRule, updateSite } from "@studiometa/forge-core";
|
|
4
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
+
import { diff } from "node:util";
|
|
7
8
|
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
8
10
|
//#region src/audit.ts
|
|
9
11
|
/**
|
|
10
12
|
* Shared audit logger for CLI write operations.
|
|
@@ -76,7 +78,14 @@ function createCommandRouter(config) {
|
|
|
76
78
|
*/
|
|
77
79
|
async function resolveServerId(value, execCtx) {
|
|
78
80
|
if (/^\d+$/.test(value)) return value;
|
|
79
|
-
|
|
81
|
+
return matchServerId(value, (await listServers({}, execCtx)).data);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Resolve a --server value to a numeric server ID string, against a list of servers
|
|
85
|
+
* that is already loaded. Same rules as {@link resolveServerId}, with no API call.
|
|
86
|
+
*/
|
|
87
|
+
function matchServerId(value, servers) {
|
|
88
|
+
if (/^\d+$/.test(value)) return value;
|
|
80
89
|
const { exact, partial } = matchByName(servers, value, (s) => s.name);
|
|
81
90
|
if (exact.length === 1) return String(exact[0].id);
|
|
82
91
|
if (partial.length === 0) {
|
|
@@ -336,13 +345,13 @@ _forge_completions() {
|
|
|
336
345
|
prev="\${COMP_WORDS[COMP_CWORD-1]}"
|
|
337
346
|
|
|
338
347
|
# Main commands
|
|
339
|
-
commands="config servers s sites deployments d databases db database-users daemons env nginx certificates certs firewall-rules fw ssh ssh-keys backups commands scheduled-jobs services user monitors nginx-templates security-rules redirect-rules recipes completion help"
|
|
348
|
+
commands="config servers s sites deployments d databases db database-users daemons env nginx certificates certs firewall-rules fw ssh ssh-keys backups commands scheduled-jobs services php user monitors nginx-templates security-rules redirect-rules recipes completion help"
|
|
340
349
|
|
|
341
350
|
# Subcommands for each command
|
|
342
351
|
local config_cmds="set get delete"
|
|
343
352
|
local servers_cmds="list ls get reboot"
|
|
344
353
|
local sites_cmds="list ls get"
|
|
345
|
-
local deployments_cmds="list ls deploy logs log"
|
|
354
|
+
local deployments_cmds="list ls deploy logs log script update-script"
|
|
346
355
|
local databases_cmds="list ls get"
|
|
347
356
|
local database_users_cmds="list ls get create delete"
|
|
348
357
|
local daemons_cmds="list ls get restart logs log"
|
|
@@ -350,11 +359,12 @@ _forge_completions() {
|
|
|
350
359
|
local nginx_cmds="get update"
|
|
351
360
|
local certificates_cmds="get activate"
|
|
352
361
|
local firewall_rules_cmds="list ls get"
|
|
353
|
-
local ssh_keys_cmds="list ls get"
|
|
362
|
+
local ssh_keys_cmds="list ls get sync"
|
|
354
363
|
local backups_cmds="list ls get create delete"
|
|
355
364
|
local commands_cmds="list ls get create"
|
|
356
365
|
local scheduled_jobs_cmds="list ls get create delete"
|
|
357
366
|
local services_cmds="list ls restart"
|
|
367
|
+
local php_cmds="list ls config update-config max-upload-size set-max-upload-size max-execution-time set-max-execution-time opcache enable-opcache disable-opcache"
|
|
358
368
|
local user_cmds="get"
|
|
359
369
|
local monitors_cmds="list ls get create delete"
|
|
360
370
|
local nginx_templates_cmds="list ls get create update delete"
|
|
@@ -364,7 +374,7 @@ _forge_completions() {
|
|
|
364
374
|
local completion_cmds="bash zsh fish"
|
|
365
375
|
|
|
366
376
|
# Global options
|
|
367
|
-
options="--token --server --site -f --format --no-color -h --help -v --version --name --command --content --from --to --provider --frequency --type --operator --threshold --minutes --key --port --ip-address --user --private --dry-run --script --servers --domain --password --credential-id --region --size --project-type --directory"
|
|
377
|
+
options="--token --server --site -f --format --no-color -h --help -v --version --name --command --content --from --to --provider --frequency --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"
|
|
368
378
|
|
|
369
379
|
# Format options
|
|
370
380
|
local formats="json human table"
|
|
@@ -426,6 +436,9 @@ _forge_completions() {
|
|
|
426
436
|
services)
|
|
427
437
|
COMPREPLY=( $(compgen -W "\${services_cmds}" -- "\${cur}") )
|
|
428
438
|
;;
|
|
439
|
+
php)
|
|
440
|
+
COMPREPLY=( $(compgen -W "\${php_cmds}" -- "\${cur}") )
|
|
441
|
+
;;
|
|
429
442
|
user)
|
|
430
443
|
COMPREPLY=( $(compgen -W "\${user_cmds}" -- "\${cur}") )
|
|
431
444
|
;;
|
|
@@ -458,7 +471,11 @@ _forge_completions() {
|
|
|
458
471
|
--format|-f)
|
|
459
472
|
COMPREPLY=( $(compgen -W "\${formats}" -- "\${cur}") )
|
|
460
473
|
;;
|
|
461
|
-
--
|
|
474
|
+
--file|--script-file)
|
|
475
|
+
# Complete file paths
|
|
476
|
+
COMPREPLY=( $(compgen -f -- "\${cur}") )
|
|
477
|
+
;;
|
|
478
|
+
--token|--server|--site|--version|--name|--command|--content|--from|--to|--provider|--frequency|--type|--operator|--threshold|--minutes|--key|--port|--ip-address|--user|--script|--servers|--domain|--password|--credential-id|--region|--size|--project-type|--directory)
|
|
462
479
|
# No completion for values requiring arguments
|
|
463
480
|
COMPREPLY=()
|
|
464
481
|
;;
|
|
@@ -510,6 +527,7 @@ _forge() {
|
|
|
510
527
|
'commands:Manage site commands'
|
|
511
528
|
'scheduled-jobs:Manage scheduled jobs'
|
|
512
529
|
'services:List and restart server services'
|
|
530
|
+
'php:Manage PHP versions, configuration, limits and OPcache'
|
|
513
531
|
'user:Display authenticated user profile'
|
|
514
532
|
'monitors:Manage server monitors'
|
|
515
533
|
'nginx-templates:Manage Nginx templates'
|
|
@@ -559,6 +577,8 @@ _forge() {
|
|
|
559
577
|
'deploy:Trigger a deployment'
|
|
560
578
|
'logs:Show deployment output'
|
|
561
579
|
'log:Show deployment output (alias)'
|
|
580
|
+
'script:Show the deployment script'
|
|
581
|
+
'update-script:Update the deployment script'
|
|
562
582
|
)
|
|
563
583
|
_describe 'deployments command' deployments_cmds
|
|
564
584
|
;;
|
|
@@ -633,6 +653,7 @@ _forge() {
|
|
|
633
653
|
'list:List SSH keys'
|
|
634
654
|
'ls:List SSH keys (alias)'
|
|
635
655
|
'get:Get SSH key details'
|
|
656
|
+
'sync:Make server SSH keys match a file'
|
|
636
657
|
)
|
|
637
658
|
_describe 'ssh-keys command' ssh_keys_cmds
|
|
638
659
|
;;
|
|
@@ -677,6 +698,23 @@ _forge() {
|
|
|
677
698
|
)
|
|
678
699
|
_describe 'services command' services_cmds
|
|
679
700
|
;;
|
|
701
|
+
php)
|
|
702
|
+
local -a php_cmds
|
|
703
|
+
php_cmds=(
|
|
704
|
+
'list:List installed PHP versions'
|
|
705
|
+
'ls:List installed PHP versions (alias)'
|
|
706
|
+
'config:Show a PHP configuration file'
|
|
707
|
+
'update-config:Replace a PHP configuration file'
|
|
708
|
+
'max-upload-size:Show the max upload size'
|
|
709
|
+
'set-max-upload-size:Set the max upload size'
|
|
710
|
+
'max-execution-time:Show the max execution time'
|
|
711
|
+
'set-max-execution-time:Set the max execution time'
|
|
712
|
+
'opcache:Show the OPcache status'
|
|
713
|
+
'enable-opcache:Enable OPcache'
|
|
714
|
+
'disable-opcache:Disable OPcache'
|
|
715
|
+
)
|
|
716
|
+
_describe 'php command' php_cmds
|
|
717
|
+
;;
|
|
680
718
|
user)
|
|
681
719
|
local -a user_cmds
|
|
682
720
|
user_cmds=(
|
|
@@ -758,7 +796,8 @@ _forge() {
|
|
|
758
796
|
'(-f --format)'{-f,--format}'[Output format]:format:(json human table)' \\
|
|
759
797
|
'--no-color[Disable colored output]' \\
|
|
760
798
|
'(-h --help)'{-h,--help}'[Show help]' \\
|
|
761
|
-
'
|
|
799
|
+
'-v[Show version]' \\
|
|
800
|
+
'--version[PHP version (php, services)]:version:' \\
|
|
762
801
|
'--name[Name]:name:' \\
|
|
763
802
|
'--command[Command]:command:' \\
|
|
764
803
|
'--content[Content]:content:' \\
|
|
@@ -782,7 +821,13 @@ _forge() {
|
|
|
782
821
|
'--region[Region]:region:' \\
|
|
783
822
|
'--size[Size]:size:' \\
|
|
784
823
|
'--project-type[Project type]:project type:' \\
|
|
785
|
-
'--directory[Directory]:directory:'
|
|
824
|
+
'--directory[Directory]:directory:' \\
|
|
825
|
+
'--file[File path (- for stdin)]:file:_files' \\
|
|
826
|
+
'--script-file[Deployment script file]:file:_files' \\
|
|
827
|
+
'--all-servers[Target every server]' \\
|
|
828
|
+
'--prune[Remove SSH keys that are not in the file]' \\
|
|
829
|
+
'--dry-run[Print the plan and change nothing]' \\
|
|
830
|
+
'--check[Compare with Forge, do not write]'
|
|
786
831
|
;;
|
|
787
832
|
esac
|
|
788
833
|
}
|
|
@@ -814,6 +859,7 @@ complete -c forge -f -n "__fish_use_subcommand" -a "backups" -d "Manage backup c
|
|
|
814
859
|
complete -c forge -f -n "__fish_use_subcommand" -a "commands" -d "Manage site commands"
|
|
815
860
|
complete -c forge -f -n "__fish_use_subcommand" -a "scheduled-jobs" -d "Manage scheduled jobs"
|
|
816
861
|
complete -c forge -f -n "__fish_use_subcommand" -a "services" -d "List and restart server services"
|
|
862
|
+
complete -c forge -f -n "__fish_use_subcommand" -a "php" -d "Manage PHP versions, configuration, limits and OPcache"
|
|
817
863
|
complete -c forge -f -n "__fish_use_subcommand" -a "user" -d "Display authenticated user profile"
|
|
818
864
|
complete -c forge -f -n "__fish_use_subcommand" -a "monitors" -d "Manage server monitors"
|
|
819
865
|
complete -c forge -f -n "__fish_use_subcommand" -a "nginx-templates" -d "Manage Nginx templates"
|
|
@@ -845,6 +891,8 @@ complete -c forge -f -n "__fish_seen_subcommand_from deployments d" -a "ls" -d "
|
|
|
845
891
|
complete -c forge -f -n "__fish_seen_subcommand_from deployments d" -a "deploy" -d "Trigger a deployment"
|
|
846
892
|
complete -c forge -f -n "__fish_seen_subcommand_from deployments d" -a "logs" -d "Show deployment output"
|
|
847
893
|
complete -c forge -f -n "__fish_seen_subcommand_from deployments d" -a "log" -d "Show deployment output (alias)"
|
|
894
|
+
complete -c forge -f -n "__fish_seen_subcommand_from deployments d" -a "script" -d "Show the deployment script"
|
|
895
|
+
complete -c forge -f -n "__fish_seen_subcommand_from deployments d" -a "update-script" -d "Update the deployment script"
|
|
848
896
|
|
|
849
897
|
# databases subcommands
|
|
850
898
|
complete -c forge -f -n "__fish_seen_subcommand_from databases db" -a "list" -d "List databases"
|
|
@@ -888,6 +936,7 @@ complete -c forge -f -n "__fish_seen_subcommand_from firewall-rules fw" -a "get"
|
|
|
888
936
|
complete -c forge -f -n "__fish_seen_subcommand_from ssh-keys" -a "list" -d "List SSH keys"
|
|
889
937
|
complete -c forge -f -n "__fish_seen_subcommand_from ssh-keys" -a "ls" -d "List SSH keys (alias)"
|
|
890
938
|
complete -c forge -f -n "__fish_seen_subcommand_from ssh-keys" -a "get" -d "Get SSH key details"
|
|
939
|
+
complete -c forge -f -n "__fish_seen_subcommand_from ssh-keys" -a "sync" -d "Make server SSH keys match a file"
|
|
891
940
|
|
|
892
941
|
# backups subcommands
|
|
893
942
|
complete -c forge -f -n "__fish_seen_subcommand_from backups" -a "list" -d "List backup configurations"
|
|
@@ -914,6 +963,19 @@ complete -c forge -f -n "__fish_seen_subcommand_from services" -a "list" -d "Lis
|
|
|
914
963
|
complete -c forge -f -n "__fish_seen_subcommand_from services" -a "ls" -d "List services on a server (alias)"
|
|
915
964
|
complete -c forge -f -n "__fish_seen_subcommand_from services" -a "restart" -d "Restart a service"
|
|
916
965
|
|
|
966
|
+
# php subcommands
|
|
967
|
+
complete -c forge -f -n "__fish_seen_subcommand_from php" -a "list" -d "List installed PHP versions"
|
|
968
|
+
complete -c forge -f -n "__fish_seen_subcommand_from php" -a "ls" -d "List installed PHP versions (alias)"
|
|
969
|
+
complete -c forge -f -n "__fish_seen_subcommand_from php" -a "config" -d "Show a PHP configuration file"
|
|
970
|
+
complete -c forge -f -n "__fish_seen_subcommand_from php" -a "update-config" -d "Replace a PHP configuration file"
|
|
971
|
+
complete -c forge -f -n "__fish_seen_subcommand_from php" -a "max-upload-size" -d "Show the max upload size"
|
|
972
|
+
complete -c forge -f -n "__fish_seen_subcommand_from php" -a "set-max-upload-size" -d "Set the max upload size"
|
|
973
|
+
complete -c forge -f -n "__fish_seen_subcommand_from php" -a "max-execution-time" -d "Show the max execution time"
|
|
974
|
+
complete -c forge -f -n "__fish_seen_subcommand_from php" -a "set-max-execution-time" -d "Set the max execution time"
|
|
975
|
+
complete -c forge -f -n "__fish_seen_subcommand_from php" -a "opcache" -d "Show the OPcache status"
|
|
976
|
+
complete -c forge -f -n "__fish_seen_subcommand_from php" -a "enable-opcache" -d "Enable OPcache"
|
|
977
|
+
complete -c forge -f -n "__fish_seen_subcommand_from php" -a "disable-opcache" -d "Disable OPcache"
|
|
978
|
+
|
|
917
979
|
# user subcommands
|
|
918
980
|
complete -c forge -f -n "__fish_seen_subcommand_from user" -a "get" -d "Get the authenticated user profile"
|
|
919
981
|
|
|
@@ -989,6 +1051,12 @@ complete -c forge -l region -d "Region" -r
|
|
|
989
1051
|
complete -c forge -l size -d "Size" -r
|
|
990
1052
|
complete -c forge -l project-type -d "Project type" -r
|
|
991
1053
|
complete -c forge -l directory -d "Directory" -r
|
|
1054
|
+
complete -c forge -l file -d "File path (- for stdin)" -r -F
|
|
1055
|
+
complete -c forge -l script-file -d "Deployment script file" -r -F
|
|
1056
|
+
complete -c forge -l all-servers -d "Target every server"
|
|
1057
|
+
complete -c forge -l prune -d "Remove SSH keys that are not in the file"
|
|
1058
|
+
complete -c forge -l dry-run -d "Print the plan and change nothing"
|
|
1059
|
+
complete -c forge -l check -d "Compare with Forge, do not write"
|
|
992
1060
|
`;
|
|
993
1061
|
/**
|
|
994
1062
|
* Get the appropriate completion file path for the shell
|
|
@@ -1863,6 +1931,133 @@ Run ${colors.cyan("forge database-users <subcommand> --help")} for details.
|
|
|
1863
1931
|
`);
|
|
1864
1932
|
}
|
|
1865
1933
|
//#endregion
|
|
1934
|
+
//#region src/utils/content-source.ts
|
|
1935
|
+
/**
|
|
1936
|
+
* Read the content of an update command from a file, stdin or an inline option.
|
|
1937
|
+
*/
|
|
1938
|
+
/**
|
|
1939
|
+
* Exit when the content is empty, so Forge never gets a blank file.
|
|
1940
|
+
*/
|
|
1941
|
+
function assertNotEmpty(content, field, source) {
|
|
1942
|
+
if (!content.trim()) exitWithError(new ValidationError(`${source.label.charAt(0).toUpperCase() + source.label.slice(1)} is empty`, field), source.formatter);
|
|
1943
|
+
}
|
|
1944
|
+
/**
|
|
1945
|
+
* Read content from a file path, or from stdin when the path is "-".
|
|
1946
|
+
* Exits with a validation error when the path is missing, the file cannot be read or it is empty.
|
|
1947
|
+
*/
|
|
1948
|
+
function readContentFile(path, field, source) {
|
|
1949
|
+
if (typeof path !== "string" || !path) exitWithError(new ValidationError(`--${field} requires a path (use - for stdin)`, field, [`Usage: ${source.usage}`]), source.formatter);
|
|
1950
|
+
let content;
|
|
1951
|
+
try {
|
|
1952
|
+
content = readFileSync(path === "-" ? 0 : path, "utf8");
|
|
1953
|
+
} catch (error) {
|
|
1954
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1955
|
+
exitWithError(new ValidationError(`Cannot read ${source.label} from ${path}: ${reason}`, field), source.formatter);
|
|
1956
|
+
}
|
|
1957
|
+
assertNotEmpty(content, field, source);
|
|
1958
|
+
return content;
|
|
1959
|
+
}
|
|
1960
|
+
/**
|
|
1961
|
+
* Read content from exactly one of `--file <path|->` or `--content <string>`.
|
|
1962
|
+
* Exits with a validation error when both or none are given, or when the content is empty.
|
|
1963
|
+
*/
|
|
1964
|
+
function readContentOption(options, source) {
|
|
1965
|
+
const { file, content } = options;
|
|
1966
|
+
if (file !== void 0 && content !== void 0) exitWithError(new ValidationError("Use either --file or --content, not both", "file", [`Usage: ${source.usage}`]), source.formatter);
|
|
1967
|
+
if (file !== void 0) return readContentFile(file, "file", source);
|
|
1968
|
+
if (content === void 0) exitWithError(new ValidationError(`Missing ${source.label}: use --file or --content`, "file", [`Usage: ${source.usage}`]), source.formatter);
|
|
1969
|
+
const inline = typeof content === "string" ? content : "";
|
|
1970
|
+
assertNotEmpty(inline, "content", source);
|
|
1971
|
+
return inline;
|
|
1972
|
+
}
|
|
1973
|
+
//#endregion
|
|
1974
|
+
//#region src/utils/drift.ts
|
|
1975
|
+
/**
|
|
1976
|
+
* Compare local content with the content on Forge for `--check`.
|
|
1977
|
+
*/
|
|
1978
|
+
/**
|
|
1979
|
+
* Normalize CRLF to LF and drop a single trailing newline,
|
|
1980
|
+
* so editor and platform differences do not count as drift.
|
|
1981
|
+
*/
|
|
1982
|
+
function normalizeContent(text) {
|
|
1983
|
+
const lf = text.replaceAll("\r\n", "\n");
|
|
1984
|
+
return lf.endsWith("\n") ? lf.slice(0, -1) : lf;
|
|
1985
|
+
}
|
|
1986
|
+
function toLines(normalized) {
|
|
1987
|
+
return normalized === "" ? [] : normalized.split("\n");
|
|
1988
|
+
}
|
|
1989
|
+
/**
|
|
1990
|
+
* Build a unified-style line diff from `remote` to `local`, with `context`
|
|
1991
|
+
* unchanged lines around each change. Returns an empty string when both
|
|
1992
|
+
* are equal after normalization.
|
|
1993
|
+
*/
|
|
1994
|
+
function unifiedDiff(remote, local, context = 3) {
|
|
1995
|
+
const remoteText = normalizeContent(remote);
|
|
1996
|
+
const localText = normalizeContent(local);
|
|
1997
|
+
if (remoteText === localText) return "";
|
|
1998
|
+
const entries = diff(toLines(remoteText), toLines(localText));
|
|
1999
|
+
let remoteCount = 0;
|
|
2000
|
+
let localCount = 0;
|
|
2001
|
+
const lines = entries.map(([op, text]) => {
|
|
2002
|
+
const line = {
|
|
2003
|
+
op: op === 0 ? " " : op === 1 ? "-" : "+",
|
|
2004
|
+
text,
|
|
2005
|
+
remoteBefore: remoteCount,
|
|
2006
|
+
localBefore: localCount
|
|
2007
|
+
};
|
|
2008
|
+
if (op !== -1) remoteCount++;
|
|
2009
|
+
if (op !== 1) localCount++;
|
|
2010
|
+
return line;
|
|
2011
|
+
});
|
|
2012
|
+
const changes = lines.flatMap((line, index) => line.op === " " ? [] : [index]);
|
|
2013
|
+
const groups = [];
|
|
2014
|
+
for (const index of changes) {
|
|
2015
|
+
const group = groups.at(-1);
|
|
2016
|
+
if (group && index - group[group.length - 1] - 1 <= 2 * context) group.push(index);
|
|
2017
|
+
else groups.push([index]);
|
|
2018
|
+
}
|
|
2019
|
+
const output = ["--- remote", "+++ local"];
|
|
2020
|
+
for (const group of groups) {
|
|
2021
|
+
const start = Math.max(0, group[0] - context);
|
|
2022
|
+
const end = Math.min(lines.length, group[group.length - 1] + context + 1);
|
|
2023
|
+
const hunk = lines.slice(start, end);
|
|
2024
|
+
const remoteLength = hunk.filter((line) => line.op !== "+").length;
|
|
2025
|
+
const localLength = hunk.filter((line) => line.op !== "-").length;
|
|
2026
|
+
const remoteStart = hunk[0].remoteBefore + (remoteLength > 0 ? 1 : 0);
|
|
2027
|
+
const localStart = hunk[0].localBefore + (localLength > 0 ? 1 : 0);
|
|
2028
|
+
output.push(`@@ -${remoteStart},${remoteLength} +${localStart},${localLength} @@`);
|
|
2029
|
+
for (const line of hunk) output.push(`${line.op}${line.text}`);
|
|
2030
|
+
}
|
|
2031
|
+
return output.join("\n");
|
|
2032
|
+
}
|
|
2033
|
+
/**
|
|
2034
|
+
* Print the result of a `--check` and set exit code 1 when the local content differs from Forge.
|
|
2035
|
+
* It sets `process.exitCode` and does not call `process.exit`, so a long diff
|
|
2036
|
+
* written to a pipe is not cut. JSON output is `{ inSync: true }` or `{ inSync: false, diff }`.
|
|
2037
|
+
*/
|
|
2038
|
+
function reportDrift(label, drift, formatter) {
|
|
2039
|
+
const name = label.charAt(0).toUpperCase() + label.slice(1);
|
|
2040
|
+
if (formatter.isJson()) formatter.output(drift ? {
|
|
2041
|
+
inSync: false,
|
|
2042
|
+
diff: drift.json
|
|
2043
|
+
} : { inSync: true });
|
|
2044
|
+
else if (drift) {
|
|
2045
|
+
console.log(drift.text);
|
|
2046
|
+
formatter.error(`${name} differs from Forge.`);
|
|
2047
|
+
} else formatter.success(`${name} is in sync with Forge.`);
|
|
2048
|
+
if (drift) process.exitCode = ExitCode.GENERAL_ERROR;
|
|
2049
|
+
}
|
|
2050
|
+
/**
|
|
2051
|
+
* Check `local` against `remote` as text and report a line diff.
|
|
2052
|
+
*/
|
|
2053
|
+
function reportTextDrift(label, remote, local, formatter) {
|
|
2054
|
+
const text = unifiedDiff(remote, local);
|
|
2055
|
+
reportDrift(label, text ? {
|
|
2056
|
+
text,
|
|
2057
|
+
json: text
|
|
2058
|
+
} : null, formatter);
|
|
2059
|
+
}
|
|
2060
|
+
//#endregion
|
|
1866
2061
|
//#region src/commands/deployments/handlers.ts
|
|
1867
2062
|
async function deploymentsList(ctx) {
|
|
1868
2063
|
const server = String(ctx.options.server ?? "");
|
|
@@ -1917,17 +2112,81 @@ async function deploymentsLogs(args, ctx) {
|
|
|
1917
2112
|
else ctx.formatter.info("No log output available.");
|
|
1918
2113
|
}, ctx.formatter);
|
|
1919
2114
|
}
|
|
2115
|
+
async function deploymentsScript(ctx) {
|
|
2116
|
+
const server = String(ctx.options.server ?? "");
|
|
2117
|
+
const site = String(ctx.options.site ?? "");
|
|
2118
|
+
if (!server) exitWithValidationError("server_id", "forge deployments script --server <server_id> --site <site_id>", ctx.formatter);
|
|
2119
|
+
if (!site) exitWithValidationError("site_id", "forge deployments script --server <server_id> --site <site_id>", ctx.formatter);
|
|
2120
|
+
await runCommand(async () => {
|
|
2121
|
+
const token = ctx.getToken();
|
|
2122
|
+
const execCtx = ctx.createExecutorContext(token);
|
|
2123
|
+
const server_id = await resolveServerId(server, execCtx);
|
|
2124
|
+
const result = await getDeploymentScript({
|
|
2125
|
+
server_id,
|
|
2126
|
+
site_id: await resolveSiteId(site, server_id, execCtx)
|
|
2127
|
+
}, execCtx);
|
|
2128
|
+
if (ctx.formatter.isJson()) console.log(JSON.stringify({ content: result.data }));
|
|
2129
|
+
else process.stdout.write(result.data.endsWith("\n") ? result.data : `${result.data}\n`);
|
|
2130
|
+
}, ctx.formatter);
|
|
2131
|
+
}
|
|
2132
|
+
async function deploymentsUpdateScript(ctx) {
|
|
2133
|
+
const usage = "forge deployments update-script --server <server_id> --site <site_id> (--file <path|-> | --content <script>) [--check]";
|
|
2134
|
+
const server = String(ctx.options.server ?? "");
|
|
2135
|
+
const site = String(ctx.options.site ?? "");
|
|
2136
|
+
const check = Boolean(ctx.options.check);
|
|
2137
|
+
if (!server) exitWithValidationError("server_id", usage, ctx.formatter);
|
|
2138
|
+
if (!site) exitWithValidationError("site_id", usage, ctx.formatter);
|
|
2139
|
+
const script = readContentOption(ctx.options, {
|
|
2140
|
+
label: "deployment script",
|
|
2141
|
+
usage,
|
|
2142
|
+
formatter: ctx.formatter
|
|
2143
|
+
});
|
|
2144
|
+
await runCommand(async () => {
|
|
2145
|
+
const token = ctx.getToken();
|
|
2146
|
+
const execCtx = ctx.createExecutorContext(token);
|
|
2147
|
+
const server_id = await resolveServerId(server, execCtx);
|
|
2148
|
+
const site_id = await resolveSiteId(site, server_id, execCtx);
|
|
2149
|
+
if (check) {
|
|
2150
|
+
reportTextDrift("deployment script", (await getDeploymentScript({
|
|
2151
|
+
server_id,
|
|
2152
|
+
site_id
|
|
2153
|
+
}, execCtx)).data, script, ctx.formatter);
|
|
2154
|
+
return;
|
|
2155
|
+
}
|
|
2156
|
+
await updateDeploymentScript({
|
|
2157
|
+
server_id,
|
|
2158
|
+
site_id,
|
|
2159
|
+
content: script
|
|
2160
|
+
}, execCtx);
|
|
2161
|
+
ctx.formatter.success("Deployment script updated.");
|
|
2162
|
+
}, ctx.formatter);
|
|
2163
|
+
}
|
|
1920
2164
|
async function deploymentsDeploy(ctx) {
|
|
2165
|
+
const usage = "forge deployments deploy --server <server_id> --site <site_id> [--script-file <path>]";
|
|
1921
2166
|
const server = String(ctx.options.server ?? "");
|
|
1922
2167
|
const site = String(ctx.options.site ?? "");
|
|
1923
2168
|
const streamLogs = Boolean(ctx.options.stream);
|
|
1924
|
-
|
|
1925
|
-
if (!
|
|
2169
|
+
const scriptFile = ctx.options["script-file"];
|
|
2170
|
+
if (!server) exitWithValidationError("server_id", usage, ctx.formatter);
|
|
2171
|
+
if (!site) exitWithValidationError("site_id", usage, ctx.formatter);
|
|
2172
|
+
const script = scriptFile === void 0 ? void 0 : readContentFile(scriptFile, "script-file", {
|
|
2173
|
+
label: "deployment script",
|
|
2174
|
+
usage,
|
|
2175
|
+
formatter: ctx.formatter
|
|
2176
|
+
});
|
|
1926
2177
|
await runCommand(async () => {
|
|
1927
2178
|
const token = ctx.getToken();
|
|
1928
2179
|
const execCtx = ctx.createExecutorContext(token);
|
|
1929
2180
|
const server_id = await resolveServerId(server, execCtx);
|
|
1930
2181
|
const site_id = await resolveSiteId(site, server_id, execCtx);
|
|
2182
|
+
if (script !== void 0) {
|
|
2183
|
+
await updateDeploymentScript({
|
|
2184
|
+
server_id,
|
|
2185
|
+
site_id,
|
|
2186
|
+
content: script
|
|
2187
|
+
}, execCtx);
|
|
2188
|
+
process.stderr.write("Deployment script updated.\n");
|
|
2189
|
+
}
|
|
1931
2190
|
const result = await deploySiteAndWait({
|
|
1932
2191
|
server_id,
|
|
1933
2192
|
site_id,
|
|
@@ -1955,9 +2214,11 @@ var handleDeploymentsCommand = createCommandRouter({
|
|
|
1955
2214
|
ls: deploymentsList,
|
|
1956
2215
|
deploy: deploymentsDeploy,
|
|
1957
2216
|
logs: [deploymentsLogs, "args"],
|
|
1958
|
-
log: [deploymentsLogs, "args"]
|
|
2217
|
+
log: [deploymentsLogs, "args"],
|
|
2218
|
+
script: deploymentsScript,
|
|
2219
|
+
"update-script": deploymentsUpdateScript
|
|
1959
2220
|
},
|
|
1960
|
-
writeSubcommands: ["deploy"]
|
|
2221
|
+
writeSubcommands: ["deploy", "update-script"]
|
|
1961
2222
|
});
|
|
1962
2223
|
//#endregion
|
|
1963
2224
|
//#region src/commands/deployments/help.ts
|
|
@@ -1983,6 +2244,44 @@ ${colors.bold("OPTIONS:")}
|
|
|
1983
2244
|
--server <id> Server ID (required)
|
|
1984
2245
|
--site <id> Site ID (required)
|
|
1985
2246
|
-f, --format <fmt> Output format: json, human, table
|
|
2247
|
+
`);
|
|
2248
|
+
else if (subcommand === "script") console.log(`
|
|
2249
|
+
${colors.bold("forge deployments script")} - Show the deployment script of a site
|
|
2250
|
+
|
|
2251
|
+
${colors.bold("USAGE:")}
|
|
2252
|
+
forge deployments script --server <server_id> --site <site_id>
|
|
2253
|
+
|
|
2254
|
+
${colors.bold("OPTIONS:")}
|
|
2255
|
+
--server <id> Server ID (required)
|
|
2256
|
+
--site <id> Site ID (required)
|
|
2257
|
+
-f, --format <fmt> Output format: json ({ content }), human (raw script)
|
|
2258
|
+
|
|
2259
|
+
${colors.bold("EXAMPLES:")}
|
|
2260
|
+
forge deployments script --server 123 --site 456 > deploy.sh
|
|
2261
|
+
`);
|
|
2262
|
+
else if (subcommand === "update-script") console.log(`
|
|
2263
|
+
${colors.bold("forge deployments update-script")} - Update the deployment script of a site
|
|
2264
|
+
|
|
2265
|
+
${colors.bold("USAGE:")}
|
|
2266
|
+
forge deployments update-script --server <server_id> --site <site_id> --file <path>
|
|
2267
|
+
forge deployments update-script --server <server_id> --site <site_id> --content <script>
|
|
2268
|
+
forge deployments update-script --server <server_id> --site <site_id> --file <path> --check
|
|
2269
|
+
|
|
2270
|
+
${colors.bold("OPTIONS:")}
|
|
2271
|
+
--server <id> Server ID (required)
|
|
2272
|
+
--site <id> Site ID (required)
|
|
2273
|
+
--file <path> Read the script from a file (use - for stdin)
|
|
2274
|
+
--content <str> Script content
|
|
2275
|
+
--check Compare with the content on Forge and do not write.
|
|
2276
|
+
Exit 0 when in sync, 1 with a line diff (- remote, + local).
|
|
2277
|
+
-f, --format <fmt> Output format: json ({ inSync, diff? } with --check), human
|
|
2278
|
+
|
|
2279
|
+
Give exactly one of --file or --content. An empty script is rejected.
|
|
2280
|
+
|
|
2281
|
+
${colors.bold("EXAMPLES:")}
|
|
2282
|
+
forge deployments update-script --server 123 --site 456 --file deploy.sh
|
|
2283
|
+
cat deploy.sh | forge deployments update-script --server 123 --site 456 --file -
|
|
2284
|
+
forge deployments update-script --server 123 --site 456 --file deploy.sh --check
|
|
1986
2285
|
`);
|
|
1987
2286
|
else if (subcommand === "deploy") console.log(`
|
|
1988
2287
|
${colors.bold("forge deployments deploy")} - Trigger a deployment
|
|
@@ -1991,10 +2290,12 @@ ${colors.bold("USAGE:")}
|
|
|
1991
2290
|
forge deployments deploy --server <server_id> --site <site_id> [options]
|
|
1992
2291
|
|
|
1993
2292
|
${colors.bold("OPTIONS:")}
|
|
1994
|
-
--server <id>
|
|
1995
|
-
--site <id>
|
|
1996
|
-
--
|
|
1997
|
-
|
|
2293
|
+
--server <id> Server ID (required)
|
|
2294
|
+
--site <id> Site ID (required)
|
|
2295
|
+
--script-file <path> Upload this deployment script before deploying (use - for stdin).
|
|
2296
|
+
If the upload fails, the deployment does not start.
|
|
2297
|
+
--stream Stream deployment logs in real-time (default: show progress)
|
|
2298
|
+
-f, --format <fmt> Output format: json, human, table
|
|
1998
2299
|
`);
|
|
1999
2300
|
else console.log(`
|
|
2000
2301
|
${colors.bold("forge deployments")} - Manage deployments
|
|
@@ -2009,16 +2310,95 @@ ${colors.bold("SUBCOMMANDS:")}
|
|
|
2009
2310
|
list, ls List deployments for a site
|
|
2010
2311
|
deploy Trigger a deployment
|
|
2011
2312
|
logs Show output of a deployment (latest if no id)
|
|
2313
|
+
script Show the deployment script
|
|
2314
|
+
update-script Update the deployment script from a file, stdin or a string
|
|
2012
2315
|
|
|
2013
2316
|
${colors.bold("OPTIONS:")}
|
|
2014
|
-
--server <id>
|
|
2015
|
-
--site <id>
|
|
2016
|
-
|
|
2317
|
+
--server <id> Server ID (required)
|
|
2318
|
+
--site <id> Site ID (required)
|
|
2319
|
+
--file <path> Script file, - for stdin (for update-script)
|
|
2320
|
+
--content <str> Script content (for update-script)
|
|
2321
|
+
--check Compare with Forge, do not write (for update-script)
|
|
2322
|
+
--script-file <path> Upload this script before deploying (for deploy)
|
|
2323
|
+
-f, --format <fmt> Output format: json, human, table
|
|
2017
2324
|
|
|
2018
2325
|
Run ${colors.cyan("forge deployments <subcommand> --help")} for details.
|
|
2019
2326
|
`);
|
|
2020
2327
|
}
|
|
2021
2328
|
//#endregion
|
|
2329
|
+
//#region src/commands/env/env-diff.ts
|
|
2330
|
+
var NAME = /^[A-Za-z_][A-Za-z0-9_.]*$/;
|
|
2331
|
+
/**
|
|
2332
|
+
* Find the closing quote of a quoted value. `\` escapes a character in double quotes only.
|
|
2333
|
+
* Returns -1 when the value is not closed.
|
|
2334
|
+
*/
|
|
2335
|
+
function closingQuote(value, quote) {
|
|
2336
|
+
for (let index = 1; index < value.length; index++) if (quote === "\"" && value[index] === "\\") index++;
|
|
2337
|
+
else if (value[index] === quote) return index;
|
|
2338
|
+
return -1;
|
|
2339
|
+
}
|
|
2340
|
+
/**
|
|
2341
|
+
* Parse a .env file into a map of name to value, with the dotenv rules used by Laravel:
|
|
2342
|
+
* an optional `export ` prefix, quoted values that can span lines, and ` #` inline comments
|
|
2343
|
+
* after unquoted values. Blank lines, comments and lines without a valid name are ignored,
|
|
2344
|
+
* so a part of a value can never show as a name.
|
|
2345
|
+
*/
|
|
2346
|
+
function parseEnv(content) {
|
|
2347
|
+
const variables = /* @__PURE__ */ new Map();
|
|
2348
|
+
const lines = content.split(/\r?\n/);
|
|
2349
|
+
for (let index = 0; index < lines.length; index++) {
|
|
2350
|
+
const line = lines[index].trim();
|
|
2351
|
+
const separator = line.indexOf("=");
|
|
2352
|
+
const name = line.slice(0, Math.max(separator, 0)).trim().replace(/^export\s+/, "");
|
|
2353
|
+
if (!NAME.test(name)) continue;
|
|
2354
|
+
let value = line.slice(separator + 1).trim();
|
|
2355
|
+
const quote = value[0];
|
|
2356
|
+
if (quote === "\"" || quote === "'") {
|
|
2357
|
+
let end = closingQuote(value, quote);
|
|
2358
|
+
while (end === -1 && index + 1 < lines.length) {
|
|
2359
|
+
index++;
|
|
2360
|
+
value += `\n${lines[index]}`;
|
|
2361
|
+
end = closingQuote(value, quote);
|
|
2362
|
+
}
|
|
2363
|
+
value = value.slice(1, end === -1 ? void 0 : end);
|
|
2364
|
+
} else value = value.replace(/\s+#.*$/, "");
|
|
2365
|
+
variables.set(name, value);
|
|
2366
|
+
}
|
|
2367
|
+
return variables;
|
|
2368
|
+
}
|
|
2369
|
+
/**
|
|
2370
|
+
* Compare the variables of two .env files. Returns only variable names.
|
|
2371
|
+
*/
|
|
2372
|
+
function diffEnv(remote, local) {
|
|
2373
|
+
const remoteVars = parseEnv(remote);
|
|
2374
|
+
const localVars = parseEnv(local);
|
|
2375
|
+
const result = {
|
|
2376
|
+
added: [],
|
|
2377
|
+
removed: [],
|
|
2378
|
+
changed: []
|
|
2379
|
+
};
|
|
2380
|
+
for (const [name, value] of localVars) if (!remoteVars.has(name)) result.added.push(name);
|
|
2381
|
+
else if (remoteVars.get(name) !== value) result.changed.push(name);
|
|
2382
|
+
for (const name of remoteVars.keys()) if (!localVars.has(name)) result.removed.push(name);
|
|
2383
|
+
return result;
|
|
2384
|
+
}
|
|
2385
|
+
/**
|
|
2386
|
+
* Turn an env diff into a drift report, or null when the variables are the same.
|
|
2387
|
+
* The report holds variable names only, never values, because CI logs are often shared.
|
|
2388
|
+
*/
|
|
2389
|
+
function envDrift(remote, local) {
|
|
2390
|
+
const result = diffEnv(remote, local);
|
|
2391
|
+
const lines = [
|
|
2392
|
+
...result.added.map((name) => `+ ${name} (added)`),
|
|
2393
|
+
...result.removed.map((name) => `- ${name} (removed)`),
|
|
2394
|
+
...result.changed.map((name) => `~ ${name} (changed)`)
|
|
2395
|
+
];
|
|
2396
|
+
return lines.length > 0 ? {
|
|
2397
|
+
text: lines.join("\n"),
|
|
2398
|
+
json: result
|
|
2399
|
+
} : null;
|
|
2400
|
+
}
|
|
2401
|
+
//#endregion
|
|
2022
2402
|
//#region src/commands/env/handlers.ts
|
|
2023
2403
|
async function envGet(ctx) {
|
|
2024
2404
|
const server = String(ctx.options.server ?? "");
|
|
@@ -2038,19 +2418,32 @@ async function envGet(ctx) {
|
|
|
2038
2418
|
}, ctx.formatter);
|
|
2039
2419
|
}
|
|
2040
2420
|
async function envUpdate(ctx) {
|
|
2421
|
+
const usage = "forge env update --server <server_id> --site <site_id> (--file <path|-> | --content <content>) [--check]";
|
|
2041
2422
|
const server = String(ctx.options.server ?? "");
|
|
2042
2423
|
const site = String(ctx.options.site ?? "");
|
|
2043
|
-
const
|
|
2044
|
-
if (!server) exitWithValidationError("server_id",
|
|
2045
|
-
if (!site) exitWithValidationError("site_id",
|
|
2046
|
-
|
|
2424
|
+
const check = Boolean(ctx.options.check);
|
|
2425
|
+
if (!server) exitWithValidationError("server_id", usage, ctx.formatter);
|
|
2426
|
+
if (!site) exitWithValidationError("site_id", usage, ctx.formatter);
|
|
2427
|
+
const content = readContentOption(ctx.options, {
|
|
2428
|
+
label: "environment file",
|
|
2429
|
+
usage,
|
|
2430
|
+
formatter: ctx.formatter
|
|
2431
|
+
});
|
|
2047
2432
|
await runCommand(async () => {
|
|
2048
2433
|
const token = ctx.getToken();
|
|
2049
2434
|
const execCtx = ctx.createExecutorContext(token);
|
|
2050
2435
|
const server_id = await resolveServerId(server, execCtx);
|
|
2436
|
+
const site_id = await resolveSiteId(site, server_id, execCtx);
|
|
2437
|
+
if (check) {
|
|
2438
|
+
reportDrift("environment file", envDrift((await getEnv({
|
|
2439
|
+
server_id,
|
|
2440
|
+
site_id
|
|
2441
|
+
}, execCtx)).data, content), ctx.formatter);
|
|
2442
|
+
return;
|
|
2443
|
+
}
|
|
2051
2444
|
await updateEnv({
|
|
2052
2445
|
server_id,
|
|
2053
|
-
site_id
|
|
2446
|
+
site_id,
|
|
2054
2447
|
content
|
|
2055
2448
|
}, execCtx);
|
|
2056
2449
|
ctx.formatter.success("Environment variables updated.");
|
|
@@ -2079,7 +2472,26 @@ ${colors.bold("USAGE:")}
|
|
|
2079
2472
|
${colors.bold("forge env update")} - Update environment variables
|
|
2080
2473
|
|
|
2081
2474
|
${colors.bold("USAGE:")}
|
|
2475
|
+
forge env update --server <server_id> --site <site_id> --file <path>
|
|
2476
|
+
forge env update --server <server_id> --site <site_id> --file <path> --check
|
|
2082
2477
|
forge env update --server <server_id> --site <site_id> --content <env_content>
|
|
2478
|
+
|
|
2479
|
+
${colors.bold("OPTIONS:")}
|
|
2480
|
+
--server <id> Server ID (required)
|
|
2481
|
+
--site <id> Site ID (required)
|
|
2482
|
+
--file <path> Read the content from a file (use - for stdin)
|
|
2483
|
+
--content <str> New env content
|
|
2484
|
+
--check Compare with the .env on Forge and do not write.
|
|
2485
|
+
Exit 0 when in sync, 1 when different. Prints only variable
|
|
2486
|
+
names (added, removed, changed), never values.
|
|
2487
|
+
-f, --format <fmt> Output format: json ({ inSync, diff? } with --check), human
|
|
2488
|
+
|
|
2489
|
+
Give exactly one of --file or --content. Empty content is rejected.
|
|
2490
|
+
|
|
2491
|
+
${colors.bold("EXAMPLES:")}
|
|
2492
|
+
forge env update --server 123 --site 456 --file .env
|
|
2493
|
+
cat .env | forge env update --server 123 --site 456 --file -
|
|
2494
|
+
forge env update --server 123 --site 456 --file .env --check
|
|
2083
2495
|
`);
|
|
2084
2496
|
else console.log(`
|
|
2085
2497
|
${colors.bold("forge env")} - Manage environment variables
|
|
@@ -2094,7 +2506,9 @@ ${colors.bold("SUBCOMMANDS:")}
|
|
|
2094
2506
|
${colors.bold("OPTIONS:")}
|
|
2095
2507
|
--server <id> Server ID (required)
|
|
2096
2508
|
--site <id> Site ID (required)
|
|
2509
|
+
--file <path> Content file, - for stdin (for update)
|
|
2097
2510
|
--content <str> New env content (for update)
|
|
2511
|
+
--check Compare with Forge, do not write (for update)
|
|
2098
2512
|
-f, --format <fmt> Output format: json, human
|
|
2099
2513
|
|
|
2100
2514
|
Run ${colors.cyan("forge env <subcommand> --help")} for details.
|
|
@@ -2365,19 +2779,32 @@ async function nginxGet(ctx) {
|
|
|
2365
2779
|
}, ctx.formatter);
|
|
2366
2780
|
}
|
|
2367
2781
|
async function nginxUpdate(ctx) {
|
|
2782
|
+
const usage = "forge nginx update --server <server_id> --site <site_id> (--file <path|-> | --content <content>) [--check]";
|
|
2368
2783
|
const server = String(ctx.options.server ?? "");
|
|
2369
2784
|
const site = String(ctx.options.site ?? "");
|
|
2370
|
-
const
|
|
2371
|
-
if (!server) exitWithValidationError("server_id",
|
|
2372
|
-
if (!site) exitWithValidationError("site_id",
|
|
2373
|
-
|
|
2785
|
+
const check = Boolean(ctx.options.check);
|
|
2786
|
+
if (!server) exitWithValidationError("server_id", usage, ctx.formatter);
|
|
2787
|
+
if (!site) exitWithValidationError("site_id", usage, ctx.formatter);
|
|
2788
|
+
const content = readContentOption(ctx.options, {
|
|
2789
|
+
label: "nginx configuration",
|
|
2790
|
+
usage,
|
|
2791
|
+
formatter: ctx.formatter
|
|
2792
|
+
});
|
|
2374
2793
|
await runCommand(async () => {
|
|
2375
2794
|
const token = ctx.getToken();
|
|
2376
2795
|
const execCtx = ctx.createExecutorContext(token);
|
|
2377
2796
|
const server_id = await resolveServerId(server, execCtx);
|
|
2797
|
+
const site_id = await resolveSiteId(site, server_id, execCtx);
|
|
2798
|
+
if (check) {
|
|
2799
|
+
reportTextDrift("nginx configuration", (await getNginxConfig({
|
|
2800
|
+
server_id,
|
|
2801
|
+
site_id
|
|
2802
|
+
}, execCtx)).data, content, ctx.formatter);
|
|
2803
|
+
return;
|
|
2804
|
+
}
|
|
2378
2805
|
await updateNginxConfig({
|
|
2379
2806
|
server_id,
|
|
2380
|
-
site_id
|
|
2807
|
+
site_id,
|
|
2381
2808
|
content
|
|
2382
2809
|
}, execCtx);
|
|
2383
2810
|
ctx.formatter.success("Nginx configuration updated.");
|
|
@@ -2406,7 +2833,25 @@ ${colors.bold("USAGE:")}
|
|
|
2406
2833
|
${colors.bold("forge nginx update")} - Update nginx configuration
|
|
2407
2834
|
|
|
2408
2835
|
${colors.bold("USAGE:")}
|
|
2836
|
+
forge nginx update --server <server_id> --site <site_id> --file <path>
|
|
2837
|
+
forge nginx update --server <server_id> --site <site_id> --file <path> --check
|
|
2409
2838
|
forge nginx update --server <server_id> --site <site_id> --content <nginx_config>
|
|
2839
|
+
|
|
2840
|
+
${colors.bold("OPTIONS:")}
|
|
2841
|
+
--server <id> Server ID (required)
|
|
2842
|
+
--site <id> Site ID (required)
|
|
2843
|
+
--file <path> Read the content from a file (use - for stdin)
|
|
2844
|
+
--content <str> New nginx config
|
|
2845
|
+
--check Compare with the content on Forge and do not write.
|
|
2846
|
+
Exit 0 when in sync, 1 with a line diff (- remote, + local).
|
|
2847
|
+
-f, --format <fmt> Output format: json ({ inSync, diff? } with --check), human
|
|
2848
|
+
|
|
2849
|
+
Give exactly one of --file or --content. Empty content is rejected.
|
|
2850
|
+
|
|
2851
|
+
${colors.bold("EXAMPLES:")}
|
|
2852
|
+
forge nginx update --server 123 --site 456 --file nginx.conf
|
|
2853
|
+
cat nginx.conf | forge nginx update --server 123 --site 456 --file -
|
|
2854
|
+
forge nginx update --server 123 --site 456 --file nginx.conf --check
|
|
2410
2855
|
`);
|
|
2411
2856
|
else console.log(`
|
|
2412
2857
|
${colors.bold("forge nginx")} - Manage nginx configuration
|
|
@@ -2421,7 +2866,9 @@ ${colors.bold("SUBCOMMANDS:")}
|
|
|
2421
2866
|
${colors.bold("OPTIONS:")}
|
|
2422
2867
|
--server <id> Server ID (required)
|
|
2423
2868
|
--site <id> Site ID (required)
|
|
2869
|
+
--file <path> Content file, - for stdin (for update)
|
|
2424
2870
|
--content <str> New nginx config (for update)
|
|
2871
|
+
--check Compare with Forge, do not write (for update)
|
|
2425
2872
|
|
|
2426
2873
|
Run ${colors.cyan("forge nginx <subcommand> --help")} for details.
|
|
2427
2874
|
`);
|
|
@@ -2611,6 +3058,328 @@ Run ${colors.cyan("forge nginx-templates <subcommand> --help")} for details.
|
|
|
2611
3058
|
`);
|
|
2612
3059
|
}
|
|
2613
3060
|
//#endregion
|
|
3061
|
+
//#region src/commands/php/handlers.ts
|
|
3062
|
+
var CONFIG_USAGE = "forge php config --server <server_id> --version <version> --type <cli|fpm|pool> [--user <user>]";
|
|
3063
|
+
var UPDATE_CONFIG_USAGE = "forge php update-config --server <server_id> --version <version> --type <cli|fpm|pool> (--file <path|-> | --content <content>) [--user <user>] [--check]";
|
|
3064
|
+
/**
|
|
3065
|
+
* Return the --server option, or exit with a validation error when it is missing.
|
|
3066
|
+
*/
|
|
3067
|
+
function requireServer(ctx, usage) {
|
|
3068
|
+
const server = String(ctx.options.server ?? "");
|
|
3069
|
+
if (!server) exitWithValidationError("server_id", usage, ctx.formatter);
|
|
3070
|
+
return server;
|
|
3071
|
+
}
|
|
3072
|
+
/**
|
|
3073
|
+
* Resolve the server and run `fn` with its ID, with the CLI error handling.
|
|
3074
|
+
*/
|
|
3075
|
+
async function withServer(ctx, server, fn) {
|
|
3076
|
+
await runCommand(async () => {
|
|
3077
|
+
const execCtx = ctx.createExecutorContext(ctx.getToken());
|
|
3078
|
+
await fn(await resolveServerId(server, execCtx), execCtx);
|
|
3079
|
+
}, ctx.formatter);
|
|
3080
|
+
}
|
|
3081
|
+
/**
|
|
3082
|
+
* Read --version, --type and --user for the config commands.
|
|
3083
|
+
* Exits with a validation error when --version is missing, --type is not cli, fpm or pool,
|
|
3084
|
+
* or --user is given for a type other than pool.
|
|
3085
|
+
*/
|
|
3086
|
+
function requireConfigTarget(ctx, usage) {
|
|
3087
|
+
const { version, type, user } = ctx.options;
|
|
3088
|
+
if (typeof version !== "string" || !version) exitWithValidationError("version", usage, ctx.formatter);
|
|
3089
|
+
if (typeof type !== "string" || !PHP_CONFIG_TYPES.includes(type)) exitWithError(new ValidationError(typeof type === "string" ? `Invalid --type "${type}"` : "type is required", "type", [`Valid types: ${PHP_CONFIG_TYPES.join(", ")}`, `Usage: ${usage}`]), ctx.formatter);
|
|
3090
|
+
if (typeof user === "string" && user && type !== "pool") exitWithError(new ValidationError(`--user applies to --type pool only, not "${type}"`, "user", [`Usage: ${usage}`]), ctx.formatter);
|
|
3091
|
+
return {
|
|
3092
|
+
version,
|
|
3093
|
+
type,
|
|
3094
|
+
user: typeof user === "string" ? user : void 0
|
|
3095
|
+
};
|
|
3096
|
+
}
|
|
3097
|
+
/**
|
|
3098
|
+
* Parse a whole number argument, or exit with a validation error.
|
|
3099
|
+
*/
|
|
3100
|
+
function requireWholeNumber(value, field, usage, ctx) {
|
|
3101
|
+
if (value === void 0 || !/^\d+$/.test(value)) exitWithError(new ValidationError(value === void 0 ? `${field} is required` : `${field} must be a whole number: "${value}"`, field, [`Usage: ${usage}`]), ctx.formatter);
|
|
3102
|
+
return Number(value);
|
|
3103
|
+
}
|
|
3104
|
+
/**
|
|
3105
|
+
* List the PHP versions installed on a server.
|
|
3106
|
+
*/
|
|
3107
|
+
async function phpList(ctx) {
|
|
3108
|
+
await withServer(ctx, requireServer(ctx, "forge php list --server <server_id>"), async (server_id, execCtx) => {
|
|
3109
|
+
const result = await listPhpVersions({ server_id }, execCtx);
|
|
3110
|
+
ctx.formatter.outputList(result.data, [
|
|
3111
|
+
"id",
|
|
3112
|
+
"version",
|
|
3113
|
+
"binary_name",
|
|
3114
|
+
"status"
|
|
3115
|
+
], "No PHP versions installed.", (v) => `${String(v.id).padEnd(8)} ${v.version.padEnd(6)} ${v.binary_name.padEnd(10)} ${v.status}`);
|
|
3116
|
+
});
|
|
3117
|
+
}
|
|
3118
|
+
/**
|
|
3119
|
+
* Print a PHP configuration file (cli, fpm or pool).
|
|
3120
|
+
*/
|
|
3121
|
+
async function phpConfig(ctx) {
|
|
3122
|
+
const server = requireServer(ctx, CONFIG_USAGE);
|
|
3123
|
+
const target = requireConfigTarget(ctx, CONFIG_USAGE);
|
|
3124
|
+
await withServer(ctx, server, async (server_id, execCtx) => {
|
|
3125
|
+
const result = await getPhpConfig({
|
|
3126
|
+
server_id,
|
|
3127
|
+
...target
|
|
3128
|
+
}, execCtx);
|
|
3129
|
+
if (ctx.formatter.isJson()) console.log(JSON.stringify({ content: result.data }));
|
|
3130
|
+
else console.log(result.data);
|
|
3131
|
+
});
|
|
3132
|
+
}
|
|
3133
|
+
/**
|
|
3134
|
+
* Replace a PHP configuration file, or compare it with Forge when --check is set.
|
|
3135
|
+
*/
|
|
3136
|
+
async function phpUpdateConfig(ctx) {
|
|
3137
|
+
const server = requireServer(ctx, UPDATE_CONFIG_USAGE);
|
|
3138
|
+
const target = requireConfigTarget(ctx, UPDATE_CONFIG_USAGE);
|
|
3139
|
+
const label = `PHP ${target.type} configuration`;
|
|
3140
|
+
const content = readContentOption(ctx.options, {
|
|
3141
|
+
label,
|
|
3142
|
+
usage: UPDATE_CONFIG_USAGE,
|
|
3143
|
+
formatter: ctx.formatter
|
|
3144
|
+
});
|
|
3145
|
+
await withServer(ctx, server, async (server_id, execCtx) => {
|
|
3146
|
+
if (ctx.options.check) {
|
|
3147
|
+
const remote = await getPhpConfig({
|
|
3148
|
+
server_id,
|
|
3149
|
+
...target
|
|
3150
|
+
}, execCtx);
|
|
3151
|
+
reportTextDrift(label, remote.data, content, ctx.formatter);
|
|
3152
|
+
return;
|
|
3153
|
+
}
|
|
3154
|
+
await updatePhpConfig({
|
|
3155
|
+
server_id,
|
|
3156
|
+
...target,
|
|
3157
|
+
content
|
|
3158
|
+
}, execCtx);
|
|
3159
|
+
ctx.formatter.success(`${label} updated.`);
|
|
3160
|
+
});
|
|
3161
|
+
}
|
|
3162
|
+
/**
|
|
3163
|
+
* Print the PHP max upload size, in megabytes.
|
|
3164
|
+
*/
|
|
3165
|
+
async function phpMaxUploadSize(ctx) {
|
|
3166
|
+
await withServer(ctx, requireServer(ctx, "forge php max-upload-size --server <server_id>"), async (server_id, execCtx) => {
|
|
3167
|
+
const { data } = await getPhpMaxUploadSize({ server_id }, execCtx);
|
|
3168
|
+
if (ctx.formatter.isJson()) ctx.formatter.output({ max_upload_size: data });
|
|
3169
|
+
else console.log(`${data} MB`);
|
|
3170
|
+
});
|
|
3171
|
+
}
|
|
3172
|
+
/**
|
|
3173
|
+
* Set the PHP max upload size, in megabytes.
|
|
3174
|
+
*/
|
|
3175
|
+
async function phpSetMaxUploadSize(args, ctx) {
|
|
3176
|
+
const usage = "forge php set-max-upload-size <megabytes> --server <server_id>";
|
|
3177
|
+
const size = requireWholeNumber(args[0], "megabytes", usage, ctx);
|
|
3178
|
+
await withServer(ctx, requireServer(ctx, usage), async (server_id, execCtx) => {
|
|
3179
|
+
await updatePhpMaxUploadSize({
|
|
3180
|
+
server_id,
|
|
3181
|
+
size
|
|
3182
|
+
}, execCtx);
|
|
3183
|
+
ctx.formatter.success(`PHP max upload size set to ${size} MB.`);
|
|
3184
|
+
});
|
|
3185
|
+
}
|
|
3186
|
+
/**
|
|
3187
|
+
* Print the PHP max execution time, in seconds.
|
|
3188
|
+
*/
|
|
3189
|
+
async function phpMaxExecutionTime(ctx) {
|
|
3190
|
+
await withServer(ctx, requireServer(ctx, "forge php max-execution-time --server <server_id>"), async (server_id, execCtx) => {
|
|
3191
|
+
const { data } = await getPhpMaxExecutionTime({ server_id }, execCtx);
|
|
3192
|
+
if (ctx.formatter.isJson()) ctx.formatter.output({ max_execution_time: data });
|
|
3193
|
+
else console.log(`${data} seconds`);
|
|
3194
|
+
});
|
|
3195
|
+
}
|
|
3196
|
+
/**
|
|
3197
|
+
* Set the PHP max execution time, in seconds.
|
|
3198
|
+
*/
|
|
3199
|
+
async function phpSetMaxExecutionTime(args, ctx) {
|
|
3200
|
+
const usage = "forge php set-max-execution-time <seconds> --server <server_id>";
|
|
3201
|
+
const seconds = requireWholeNumber(args[0], "seconds", usage, ctx);
|
|
3202
|
+
await withServer(ctx, requireServer(ctx, usage), async (server_id, execCtx) => {
|
|
3203
|
+
await updatePhpMaxExecutionTime({
|
|
3204
|
+
server_id,
|
|
3205
|
+
seconds
|
|
3206
|
+
}, execCtx);
|
|
3207
|
+
ctx.formatter.success(`PHP max execution time set to ${seconds} seconds.`);
|
|
3208
|
+
});
|
|
3209
|
+
}
|
|
3210
|
+
/**
|
|
3211
|
+
* Print whether OPcache is enabled.
|
|
3212
|
+
*/
|
|
3213
|
+
async function phpOpcache(ctx) {
|
|
3214
|
+
await withServer(ctx, requireServer(ctx, "forge php opcache --server <server_id>"), async (server_id, execCtx) => {
|
|
3215
|
+
const { data } = await getPhpOpcache({ server_id }, execCtx);
|
|
3216
|
+
if (ctx.formatter.isJson()) ctx.formatter.output({ opcache_enabled: data });
|
|
3217
|
+
else console.log(`OPcache is ${data ? "enabled" : "disabled"}.`);
|
|
3218
|
+
});
|
|
3219
|
+
}
|
|
3220
|
+
/**
|
|
3221
|
+
* Enable OPcache.
|
|
3222
|
+
*/
|
|
3223
|
+
async function phpEnableOpcache(ctx) {
|
|
3224
|
+
await withServer(ctx, requireServer(ctx, "forge php enable-opcache --server <server_id>"), async (server_id, execCtx) => {
|
|
3225
|
+
await enablePhpOpcache({ server_id }, execCtx);
|
|
3226
|
+
ctx.formatter.success("OPcache enabled.");
|
|
3227
|
+
});
|
|
3228
|
+
}
|
|
3229
|
+
/**
|
|
3230
|
+
* Disable OPcache.
|
|
3231
|
+
*/
|
|
3232
|
+
async function phpDisableOpcache(ctx) {
|
|
3233
|
+
await withServer(ctx, requireServer(ctx, "forge php disable-opcache --server <server_id>"), async (server_id, execCtx) => {
|
|
3234
|
+
await disablePhpOpcache({ server_id }, execCtx);
|
|
3235
|
+
ctx.formatter.success("OPcache disabled.");
|
|
3236
|
+
});
|
|
3237
|
+
}
|
|
3238
|
+
//#endregion
|
|
3239
|
+
//#region src/commands/php/command.ts
|
|
3240
|
+
var handlePhpCommand = createCommandRouter({
|
|
3241
|
+
resource: "php",
|
|
3242
|
+
handlers: {
|
|
3243
|
+
list: phpList,
|
|
3244
|
+
ls: phpList,
|
|
3245
|
+
config: phpConfig,
|
|
3246
|
+
"update-config": phpUpdateConfig,
|
|
3247
|
+
"max-upload-size": phpMaxUploadSize,
|
|
3248
|
+
"set-max-upload-size": [phpSetMaxUploadSize, "args"],
|
|
3249
|
+
"max-execution-time": phpMaxExecutionTime,
|
|
3250
|
+
"set-max-execution-time": [phpSetMaxExecutionTime, "args"],
|
|
3251
|
+
opcache: phpOpcache,
|
|
3252
|
+
"enable-opcache": phpEnableOpcache,
|
|
3253
|
+
"disable-opcache": phpDisableOpcache
|
|
3254
|
+
},
|
|
3255
|
+
writeSubcommands: [
|
|
3256
|
+
"update-config",
|
|
3257
|
+
"set-max-upload-size",
|
|
3258
|
+
"set-max-execution-time",
|
|
3259
|
+
"enable-opcache",
|
|
3260
|
+
"disable-opcache"
|
|
3261
|
+
]
|
|
3262
|
+
});
|
|
3263
|
+
//#endregion
|
|
3264
|
+
//#region src/commands/php/help.ts
|
|
3265
|
+
function showPhpHelp(subcommand) {
|
|
3266
|
+
if (subcommand === "list" || subcommand === "ls") console.log(`
|
|
3267
|
+
${colors.bold("forge php list")} - List the PHP versions installed on a server
|
|
3268
|
+
|
|
3269
|
+
${colors.bold("USAGE:")}
|
|
3270
|
+
forge php list --server <server_id>
|
|
3271
|
+
|
|
3272
|
+
${colors.bold("OPTIONS:")}
|
|
3273
|
+
--server <id> Server ID or name (required)
|
|
3274
|
+
-f, --format <fmt> Output format: json, human, table
|
|
3275
|
+
|
|
3276
|
+
${colors.bold("EXAMPLES:")}
|
|
3277
|
+
forge php list --server 123
|
|
3278
|
+
`);
|
|
3279
|
+
else if (subcommand === "config") console.log(`
|
|
3280
|
+
${colors.bold("forge php config")} - Print a PHP configuration file
|
|
3281
|
+
|
|
3282
|
+
${colors.bold("USAGE:")}
|
|
3283
|
+
forge php config --server <server_id> --version <version> --type <cli|fpm|pool>
|
|
3284
|
+
|
|
3285
|
+
${colors.bold("OPTIONS:")}
|
|
3286
|
+
--server <id> Server ID or name (required)
|
|
3287
|
+
--version <ver> PHP version ID, or a version such as 8.3, php83 or php8.3 (required)
|
|
3288
|
+
--type <type> cli (CLI php.ini), fpm (FPM php.ini) or pool (FPM pool) (required)
|
|
3289
|
+
--user <name> FPM pool user (pool only)
|
|
3290
|
+
-f, --format <fmt> Output format: json ({ content }), human
|
|
3291
|
+
|
|
3292
|
+
${colors.bold("EXAMPLES:")}
|
|
3293
|
+
forge php config --server 123 --version 8.3 --type fpm
|
|
3294
|
+
forge php config --server 123 --version 8.3 --type pool > www.conf
|
|
3295
|
+
`);
|
|
3296
|
+
else if (subcommand === "update-config") console.log(`
|
|
3297
|
+
${colors.bold("forge php update-config")} - Replace a PHP configuration file
|
|
3298
|
+
|
|
3299
|
+
${colors.bold("USAGE:")}
|
|
3300
|
+
forge php update-config --server <server_id> --version <version> --type <cli|fpm|pool> --file <path>
|
|
3301
|
+
forge php update-config --server <server_id> --version <version> --type <cli|fpm|pool> --file <path> --check
|
|
3302
|
+
forge php update-config --server <server_id> --version <version> --type <cli|fpm|pool> --content <config>
|
|
3303
|
+
|
|
3304
|
+
${colors.bold("OPTIONS:")}
|
|
3305
|
+
--server <id> Server ID or name (required)
|
|
3306
|
+
--version <ver> PHP version ID, or a version such as 8.3, php83 or php8.3 (required)
|
|
3307
|
+
--type <type> cli (CLI php.ini), fpm (FPM php.ini) or pool (FPM pool) (required)
|
|
3308
|
+
--user <name> FPM pool user (pool only)
|
|
3309
|
+
--file <path> Read the content from a file (use - for stdin)
|
|
3310
|
+
--content <str> New configuration file content
|
|
3311
|
+
--check Compare with the content on Forge and do not write.
|
|
3312
|
+
Exit 0 when in sync, 1 with a line diff (- remote, + local).
|
|
3313
|
+
-f, --format <fmt> Output format: json ({ inSync, diff? } with --check), human
|
|
3314
|
+
|
|
3315
|
+
Give exactly one of --file or --content. Empty content is rejected.
|
|
3316
|
+
The content replaces the whole file.
|
|
3317
|
+
|
|
3318
|
+
${colors.bold("EXAMPLES:")}
|
|
3319
|
+
forge php update-config --server 123 --version 8.3 --type fpm --file php.ini
|
|
3320
|
+
cat php.ini | forge php update-config --server 123 --version 8.3 --type cli --file -
|
|
3321
|
+
forge php update-config --server 123 --version 8.3 --type fpm --file php.ini --check
|
|
3322
|
+
`);
|
|
3323
|
+
else if (subcommand === "max-upload-size" || subcommand === "set-max-upload-size" || subcommand === "max-execution-time" || subcommand === "set-max-execution-time") console.log(`
|
|
3324
|
+
${colors.bold("forge php max-upload-size / max-execution-time")} - Get or set PHP limits
|
|
3325
|
+
|
|
3326
|
+
${colors.bold("USAGE:")}
|
|
3327
|
+
forge php max-upload-size --server <server_id>
|
|
3328
|
+
forge php set-max-upload-size <megabytes> --server <server_id>
|
|
3329
|
+
forge php max-execution-time --server <server_id>
|
|
3330
|
+
forge php set-max-execution-time <seconds> --server <server_id>
|
|
3331
|
+
|
|
3332
|
+
${colors.bold("OPTIONS:")}
|
|
3333
|
+
--server <id> Server ID or name (required)
|
|
3334
|
+
-f, --format <fmt> Output format: json, human
|
|
3335
|
+
|
|
3336
|
+
${colors.bold("EXAMPLES:")}
|
|
3337
|
+
forge php set-max-upload-size 128 --server 123
|
|
3338
|
+
forge php set-max-execution-time 120 --server 123
|
|
3339
|
+
`);
|
|
3340
|
+
else if (subcommand === "opcache" || subcommand === "enable-opcache" || subcommand === "disable-opcache") console.log(`
|
|
3341
|
+
${colors.bold("forge php opcache")} - Get, enable or disable OPcache
|
|
3342
|
+
|
|
3343
|
+
${colors.bold("USAGE:")}
|
|
3344
|
+
forge php opcache --server <server_id>
|
|
3345
|
+
forge php enable-opcache --server <server_id>
|
|
3346
|
+
forge php disable-opcache --server <server_id>
|
|
3347
|
+
|
|
3348
|
+
${colors.bold("OPTIONS:")}
|
|
3349
|
+
--server <id> Server ID or name (required)
|
|
3350
|
+
-f, --format <fmt> Output format: json, human
|
|
3351
|
+
`);
|
|
3352
|
+
else console.log(`
|
|
3353
|
+
${colors.bold("forge php")} - Manage PHP on a server
|
|
3354
|
+
|
|
3355
|
+
${colors.bold("USAGE:")}
|
|
3356
|
+
forge php <subcommand> [options]
|
|
3357
|
+
|
|
3358
|
+
${colors.bold("SUBCOMMANDS:")}
|
|
3359
|
+
list, ls List installed PHP versions
|
|
3360
|
+
config Print a configuration file (--version --type)
|
|
3361
|
+
update-config Replace a configuration file (--version --type --file|--content) [--check]
|
|
3362
|
+
max-upload-size Print the max upload size (MB)
|
|
3363
|
+
set-max-upload-size <mb> Set the max upload size (MB)
|
|
3364
|
+
max-execution-time Print the max execution time (seconds)
|
|
3365
|
+
set-max-execution-time <sec> Set the max execution time (seconds)
|
|
3366
|
+
opcache Print the OPcache status
|
|
3367
|
+
enable-opcache Enable OPcache
|
|
3368
|
+
disable-opcache Disable OPcache
|
|
3369
|
+
|
|
3370
|
+
${colors.bold("OPTIONS:")}
|
|
3371
|
+
--server <id> Server ID or name (required)
|
|
3372
|
+
--version <ver> PHP version ID or version (8.3, php83) (for config commands)
|
|
3373
|
+
--type <type> cli, fpm or pool (for config commands)
|
|
3374
|
+
--user <name> FPM pool user (for --type pool)
|
|
3375
|
+
--file <path> Content file, - for stdin (for update-config)
|
|
3376
|
+
--content <str> New content (for update-config)
|
|
3377
|
+
--check Compare with Forge, do not write (for update-config)
|
|
3378
|
+
|
|
3379
|
+
Run ${colors.cyan("forge php <subcommand> --help")} for details.
|
|
3380
|
+
`);
|
|
3381
|
+
}
|
|
3382
|
+
//#endregion
|
|
2614
3383
|
//#region src/commands/recipes/handlers.ts
|
|
2615
3384
|
async function recipesList(ctx) {
|
|
2616
3385
|
await runCommand(async () => {
|
|
@@ -3449,7 +4218,7 @@ async function servicesRestart(args, ctx) {
|
|
|
3449
4218
|
const [server, service] = args;
|
|
3450
4219
|
if (!server) exitWithValidationError("server", "forge services restart <server> <service>", ctx.formatter);
|
|
3451
4220
|
if (!service) exitWithValidationError("service", "forge services restart <server> <service>", ctx.formatter);
|
|
3452
|
-
const version = ctx.options.version ?
|
|
4221
|
+
const version = typeof ctx.options.version === "string" && ctx.options.version ? ctx.options.version : void 0;
|
|
3453
4222
|
await runCommand(async () => {
|
|
3454
4223
|
if (!RESTARTABLE_SERVICES.includes(service)) throw new ValidationError(`Invalid service "${service}"`, "service", [`Valid services: ${RESTARTABLE_SERVICES.join(", ")}`]);
|
|
3455
4224
|
const token = ctx.getToken();
|
|
@@ -3876,14 +4645,285 @@ async function sshKeysGet(args, ctx) {
|
|
|
3876
4645
|
}, ctx.formatter);
|
|
3877
4646
|
}
|
|
3878
4647
|
//#endregion
|
|
4648
|
+
//#region src/commands/ssh-keys/sync-plan.ts
|
|
4649
|
+
/**
|
|
4650
|
+
* Pure logic for `forge ssh-keys sync`: parse an authorized_keys file, give each key
|
|
4651
|
+
* a stable managed name, and compare the file with the keys on a server.
|
|
4652
|
+
*
|
|
4653
|
+
* The Forge API does not return public keys, so keys are matched by name. The name
|
|
4654
|
+
* ends with a fingerprint of the key, so a changed key gets a new name.
|
|
4655
|
+
*/
|
|
4656
|
+
/** The Forge API documents no name limit: keep names short enough to read in a list. */
|
|
4657
|
+
var MAX_SLUG_LENGTH = 40;
|
|
4658
|
+
var KEY_TYPE = /^(ssh-|ecdsa-sha2-|sk-)[a-z0-9@.-]+$/;
|
|
4659
|
+
var BASE64 = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
4660
|
+
/**
|
|
4661
|
+
* Check the base64 key data of a public key, as OpenSSH does: it must be canonical
|
|
4662
|
+
* base64, and the decoded key must start with its own type as a length-prefixed string.
|
|
4663
|
+
*/
|
|
4664
|
+
function isValidKeyBlob(type, blob) {
|
|
4665
|
+
if (!BASE64.test(blob) || blob.length % 4 !== 0) return false;
|
|
4666
|
+
const data = Buffer.from(blob, "base64");
|
|
4667
|
+
if (data.toString("base64") !== blob || data.length < 4) return false;
|
|
4668
|
+
const typeLength = data.readUInt32BE(0);
|
|
4669
|
+
return 4 + typeLength <= data.length && data.toString("latin1", 4, 4 + typeLength) === type;
|
|
4670
|
+
}
|
|
4671
|
+
/**
|
|
4672
|
+
* Convert a key comment to a name prefix: lowercase, `[a-z0-9-]` only, "key" when empty.
|
|
4673
|
+
*/
|
|
4674
|
+
function slugify(comment) {
|
|
4675
|
+
return comment.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-").slice(0, MAX_SLUG_LENGTH).replaceAll(/^-+|-+$/g, "") || "key";
|
|
4676
|
+
}
|
|
4677
|
+
/**
|
|
4678
|
+
* Managed name of a key: "<slug(comment)>-<first 8 hex chars of the SHA-256 of the key blob>".
|
|
4679
|
+
* The blob is the decoded base64 field, as in an OpenSSH fingerprint.
|
|
4680
|
+
*/
|
|
4681
|
+
function managedKeyName(comment, blob) {
|
|
4682
|
+
const hash = createHash("sha256").update(Buffer.from(blob, "base64")).digest("hex");
|
|
4683
|
+
return `${slugify(comment)}-${hash.slice(0, 8)}`;
|
|
4684
|
+
}
|
|
4685
|
+
/**
|
|
4686
|
+
* Parse an authorized_keys file: one "<type> <base64> [comment]" per line.
|
|
4687
|
+
* Blank lines and "#" comments are ignored.
|
|
4688
|
+
*
|
|
4689
|
+
* @throws ValidationError for a malformed or duplicate key, with its line number.
|
|
4690
|
+
*/
|
|
4691
|
+
function parseAuthorizedKeys(content) {
|
|
4692
|
+
const keys = [];
|
|
4693
|
+
const seen = /* @__PURE__ */ new Map();
|
|
4694
|
+
for (const [index, raw] of content.split(/\r?\n/).entries()) {
|
|
4695
|
+
const line = index + 1;
|
|
4696
|
+
const text = raw.trim();
|
|
4697
|
+
if (!text || text.startsWith("#")) continue;
|
|
4698
|
+
const [type, blob = "", ...rest] = text.split(/\s+/);
|
|
4699
|
+
const problem = !KEY_TYPE.test(type) ? "unknown key type" : !isValidKeyBlob(type, blob) ? "missing or invalid base64 key data" : void 0;
|
|
4700
|
+
if (problem) throw new ValidationError(`Line ${line}: ${problem}, expected "<type> <base64> [comment]"`, "file", ["Use one public key per line, as in ~/.ssh/authorized_keys (no options prefix)"]);
|
|
4701
|
+
const first = seen.get(blob);
|
|
4702
|
+
if (first !== void 0) throw new ValidationError(`Line ${line}: duplicate of the key on line ${first}`, "file");
|
|
4703
|
+
seen.set(blob, line);
|
|
4704
|
+
const comment = rest.join(" ");
|
|
4705
|
+
keys.push({
|
|
4706
|
+
name: managedKeyName(comment, blob),
|
|
4707
|
+
key: [
|
|
4708
|
+
type,
|
|
4709
|
+
blob,
|
|
4710
|
+
comment
|
|
4711
|
+
].filter(Boolean).join(" "),
|
|
4712
|
+
line
|
|
4713
|
+
});
|
|
4714
|
+
}
|
|
4715
|
+
return keys;
|
|
4716
|
+
}
|
|
4717
|
+
/**
|
|
4718
|
+
* Compare the file keys with the keys on a server, for one user.
|
|
4719
|
+
* Keys of other users are ignored.
|
|
4720
|
+
*/
|
|
4721
|
+
function planSync(fileKeys, serverKeys, user) {
|
|
4722
|
+
const userKeys = serverKeys.filter((k) => k.user === user);
|
|
4723
|
+
const serverNames = new Set(userKeys.map((k) => k.name));
|
|
4724
|
+
const fileNames = new Set(fileKeys.map((k) => k.name));
|
|
4725
|
+
return {
|
|
4726
|
+
add: fileKeys.filter((k) => !serverNames.has(k.name)),
|
|
4727
|
+
remove: userKeys.filter((k) => !fileNames.has(k.name)),
|
|
4728
|
+
unchanged: fileKeys.filter((k) => serverNames.has(k.name))
|
|
4729
|
+
};
|
|
4730
|
+
}
|
|
4731
|
+
//#endregion
|
|
4732
|
+
//#region src/commands/ssh-keys/sync.ts
|
|
4733
|
+
var USAGE = "forge ssh-keys sync --file <path|-> (--server <id>... | --all-servers) [--user <name>] [--prune] [--dry-run]";
|
|
4734
|
+
function errorMessage(error) {
|
|
4735
|
+
return error instanceof Error ? error.message : String(error);
|
|
4736
|
+
}
|
|
4737
|
+
/**
|
|
4738
|
+
* Read the --server values: repeated and/or comma separated.
|
|
4739
|
+
*/
|
|
4740
|
+
function serverValues(value) {
|
|
4741
|
+
return [value].flat().filter((v) => typeof v === "string").flatMap((v) => v.split(",")).map((v) => v.trim()).filter(Boolean);
|
|
4742
|
+
}
|
|
4743
|
+
/**
|
|
4744
|
+
* Read and parse the --file option. Exits on a missing, unreadable, malformed or empty file.
|
|
4745
|
+
*/
|
|
4746
|
+
function readFileKeys(ctx) {
|
|
4747
|
+
const { file } = ctx.options;
|
|
4748
|
+
const content = readContentFile(file, "file", {
|
|
4749
|
+
label: "SSH keys file",
|
|
4750
|
+
usage: USAGE,
|
|
4751
|
+
formatter: ctx.formatter
|
|
4752
|
+
});
|
|
4753
|
+
let keys;
|
|
4754
|
+
try {
|
|
4755
|
+
keys = parseAuthorizedKeys(content);
|
|
4756
|
+
} catch (error) {
|
|
4757
|
+
exitWithError(error, ctx.formatter);
|
|
4758
|
+
}
|
|
4759
|
+
if (keys.length === 0) exitWithError(new ValidationError(`No SSH keys found in ${String(file)}`, "file"), ctx.formatter);
|
|
4760
|
+
return keys;
|
|
4761
|
+
}
|
|
4762
|
+
/**
|
|
4763
|
+
* Resolve the target servers to ids and names, without duplicates.
|
|
4764
|
+
* Names are matched against one list of servers: one API call for all targets.
|
|
4765
|
+
*/
|
|
4766
|
+
async function resolveTargets(values, allServers, execCtx) {
|
|
4767
|
+
const servers = (await listServers({}, execCtx)).data;
|
|
4768
|
+
if (allServers) return servers.map((s) => ({
|
|
4769
|
+
id: s.id,
|
|
4770
|
+
name: s.name
|
|
4771
|
+
}));
|
|
4772
|
+
const targets = /* @__PURE__ */ new Map();
|
|
4773
|
+
for (const value of values) {
|
|
4774
|
+
const id = Number(matchServerId(value, servers));
|
|
4775
|
+
targets.set(id, servers.find((s) => s.id === id)?.name ?? String(id));
|
|
4776
|
+
}
|
|
4777
|
+
return [...targets].map(([id, name]) => ({
|
|
4778
|
+
id,
|
|
4779
|
+
name
|
|
4780
|
+
}));
|
|
4781
|
+
}
|
|
4782
|
+
function printPlan(syncs, user, prune) {
|
|
4783
|
+
for (const sync of syncs) {
|
|
4784
|
+
console.log(colors.bold(`${sync.name} (${sync.id})`));
|
|
4785
|
+
for (const error of sync.errors) console.log(colors.red(` ✗ ${error}`));
|
|
4786
|
+
if (!sync.plan) continue;
|
|
4787
|
+
const { add, remove, unchanged } = sync.plan;
|
|
4788
|
+
for (const key of add) console.log(colors.green(` + ${key.name}`));
|
|
4789
|
+
if (prune) for (const key of remove) console.log(colors.red(` - ${key.name}`));
|
|
4790
|
+
for (const key of unchanged) console.log(colors.dim(` = ${key.name}`));
|
|
4791
|
+
if (!prune && remove.length > 0) console.log(colors.yellow(` ${remove.length} key(s) of user "${user}" not in the file are kept (use --prune to remove them)`));
|
|
4792
|
+
}
|
|
4793
|
+
}
|
|
4794
|
+
function printResult(syncs) {
|
|
4795
|
+
for (const sync of syncs) {
|
|
4796
|
+
const summary = `${sync.name} (${sync.id}): ${sync.added.length} added, ${sync.removed.length} removed, ${sync.unchanged.length} unchanged`;
|
|
4797
|
+
if (sync.errors.length === 0) {
|
|
4798
|
+
console.log(colors.green(`✓ ${summary}`));
|
|
4799
|
+
continue;
|
|
4800
|
+
}
|
|
4801
|
+
console.log(colors.red(`✗ ${summary}, ${sync.errors.length} failed`));
|
|
4802
|
+
for (const error of sync.errors) console.log(colors.red(` ${error}`));
|
|
4803
|
+
}
|
|
4804
|
+
}
|
|
4805
|
+
/**
|
|
4806
|
+
* Apply a plan to one server. Each failed operation is recorded and the next one runs.
|
|
4807
|
+
* When an add fails, the removals are skipped: the user must not lose access to the server.
|
|
4808
|
+
*/
|
|
4809
|
+
async function applyPlan(sync, plan, user, prune, execCtx) {
|
|
4810
|
+
const server_id = String(sync.id);
|
|
4811
|
+
let addFailed = false;
|
|
4812
|
+
for (const key of plan.add) try {
|
|
4813
|
+
await createSshKey({
|
|
4814
|
+
server_id,
|
|
4815
|
+
name: key.name,
|
|
4816
|
+
key: key.key,
|
|
4817
|
+
user
|
|
4818
|
+
}, execCtx);
|
|
4819
|
+
sync.added.push(key.name);
|
|
4820
|
+
} catch (error) {
|
|
4821
|
+
addFailed = true;
|
|
4822
|
+
sync.errors.push(`add ${key.name}: ${errorMessage(error)}`);
|
|
4823
|
+
}
|
|
4824
|
+
if (!prune) return;
|
|
4825
|
+
for (const key of plan.remove) {
|
|
4826
|
+
if (addFailed) {
|
|
4827
|
+
sync.errors.push(`remove ${key.name}: skipped because an add failed`);
|
|
4828
|
+
continue;
|
|
4829
|
+
}
|
|
4830
|
+
try {
|
|
4831
|
+
await deleteSshKey({
|
|
4832
|
+
server_id,
|
|
4833
|
+
id: String(key.id)
|
|
4834
|
+
}, execCtx);
|
|
4835
|
+
sync.removed.push(key.name);
|
|
4836
|
+
} catch (error) {
|
|
4837
|
+
sync.errors.push(`remove ${key.name}: ${errorMessage(error)}`);
|
|
4838
|
+
}
|
|
4839
|
+
}
|
|
4840
|
+
}
|
|
4841
|
+
/**
|
|
4842
|
+
* Make the SSH keys of a user on one or more servers match an authorized_keys file.
|
|
4843
|
+
*/
|
|
4844
|
+
async function sshKeysSync(ctx) {
|
|
4845
|
+
const { options, formatter } = ctx;
|
|
4846
|
+
const servers = serverValues(options.server);
|
|
4847
|
+
const allServers = options["all-servers"] === true;
|
|
4848
|
+
const userOption = options.user;
|
|
4849
|
+
const prune = options.prune === true;
|
|
4850
|
+
const dryRun = options["dry-run"] === true;
|
|
4851
|
+
if (options.file === void 0) exitWithValidationError("file", USAGE, formatter);
|
|
4852
|
+
if (userOption !== void 0 && (typeof userOption !== "string" || userOption === "")) exitWithError(new ValidationError("--user requires a name", "user", [`Usage: ${USAGE}`]), formatter);
|
|
4853
|
+
const user = typeof userOption === "string" ? userOption : "forge";
|
|
4854
|
+
if (allServers && options.server !== void 0) exitWithError(new ValidationError("Use either --server or --all-servers, not both", "server", [`Usage: ${USAGE}`]), formatter);
|
|
4855
|
+
if (!allServers && servers.length === 0) exitWithError(new ValidationError("A target is required: --server <id> or --all-servers", "server", [`Usage: ${USAGE}`]), formatter);
|
|
4856
|
+
const fileKeys = readFileKeys(ctx);
|
|
4857
|
+
const failed = await runCommand(async () => {
|
|
4858
|
+
const execCtx = ctx.createExecutorContext(ctx.getToken());
|
|
4859
|
+
const targets = await resolveTargets(servers, allServers, execCtx);
|
|
4860
|
+
const syncs = [];
|
|
4861
|
+
for (const { id, name } of targets) {
|
|
4862
|
+
const sync = {
|
|
4863
|
+
id,
|
|
4864
|
+
name,
|
|
4865
|
+
added: [],
|
|
4866
|
+
removed: [],
|
|
4867
|
+
unchanged: [],
|
|
4868
|
+
errors: []
|
|
4869
|
+
};
|
|
4870
|
+
try {
|
|
4871
|
+
const keys = await listSshKeys({
|
|
4872
|
+
server_id: String(id),
|
|
4873
|
+
user
|
|
4874
|
+
}, execCtx);
|
|
4875
|
+
sync.plan = planSync(fileKeys, keys.data, user);
|
|
4876
|
+
sync.unchanged = sync.plan.unchanged.map((k) => k.name);
|
|
4877
|
+
} catch (error) {
|
|
4878
|
+
sync.errors.push(`list keys: ${errorMessage(error)}`);
|
|
4879
|
+
}
|
|
4880
|
+
syncs.push(sync);
|
|
4881
|
+
}
|
|
4882
|
+
const json = formatter.isJson();
|
|
4883
|
+
if (!json) printPlan(syncs, user, prune);
|
|
4884
|
+
if (dryRun) {
|
|
4885
|
+
for (const sync of syncs) {
|
|
4886
|
+
if (!sync.plan) continue;
|
|
4887
|
+
sync.added = sync.plan.add.map((k) => k.name);
|
|
4888
|
+
if (prune) sync.removed = sync.plan.remove.map((k) => k.name);
|
|
4889
|
+
}
|
|
4890
|
+
if (!json) console.log(colors.cyan("\nDry run: no changes made."));
|
|
4891
|
+
} else {
|
|
4892
|
+
for (const sync of syncs) if (sync.plan) await applyPlan(sync, sync.plan, user, prune, execCtx);
|
|
4893
|
+
if (!json) {
|
|
4894
|
+
console.log("");
|
|
4895
|
+
printResult(syncs);
|
|
4896
|
+
}
|
|
4897
|
+
}
|
|
4898
|
+
if (json) console.log(JSON.stringify({
|
|
4899
|
+
dry_run: dryRun,
|
|
4900
|
+
servers: syncs.map(({ id, name, added, removed, unchanged, errors }) => ({
|
|
4901
|
+
id,
|
|
4902
|
+
name,
|
|
4903
|
+
added,
|
|
4904
|
+
removed,
|
|
4905
|
+
unchanged,
|
|
4906
|
+
errors
|
|
4907
|
+
}))
|
|
4908
|
+
}, null, 2));
|
|
4909
|
+
return syncs.reduce((count, sync) => count + sync.errors.length, 0);
|
|
4910
|
+
}, formatter);
|
|
4911
|
+
if (failed) {
|
|
4912
|
+
if (!formatter.isJson()) formatter.error(`${failed} SSH key operation(s) failed`);
|
|
4913
|
+
process.exit(ExitCode.GENERAL_ERROR);
|
|
4914
|
+
}
|
|
4915
|
+
}
|
|
4916
|
+
//#endregion
|
|
3879
4917
|
//#region src/commands/ssh-keys/command.ts
|
|
3880
4918
|
var handleSshKeysCommand = createCommandRouter({
|
|
3881
4919
|
resource: "ssh-keys",
|
|
3882
4920
|
handlers: {
|
|
3883
4921
|
list: sshKeysList,
|
|
3884
4922
|
ls: sshKeysList,
|
|
3885
|
-
get: [sshKeysGet, "args"]
|
|
3886
|
-
|
|
4923
|
+
get: [sshKeysGet, "args"],
|
|
4924
|
+
sync: sshKeysSync
|
|
4925
|
+
},
|
|
4926
|
+
writeSubcommands: ["sync"]
|
|
3887
4927
|
});
|
|
3888
4928
|
//#endregion
|
|
3889
4929
|
//#region src/commands/ssh-keys/help.ts
|
|
@@ -3899,6 +4939,41 @@ ${colors.bold("forge ssh-keys get")} - Get SSH key details
|
|
|
3899
4939
|
|
|
3900
4940
|
${colors.bold("USAGE:")}
|
|
3901
4941
|
forge ssh-keys get <key_id> --server <server_id>
|
|
4942
|
+
`);
|
|
4943
|
+
else if (subcommand === "sync") console.log(`
|
|
4944
|
+
${colors.bold("forge ssh-keys sync")} - Make the SSH keys of servers match a file
|
|
4945
|
+
|
|
4946
|
+
${colors.bold("USAGE:")}
|
|
4947
|
+
forge ssh-keys sync --file <path|-> --server <id> [--server <id>...] [options]
|
|
4948
|
+
forge ssh-keys sync --file <path|-> --all-servers [options]
|
|
4949
|
+
|
|
4950
|
+
${colors.bold("OPTIONS:")}
|
|
4951
|
+
--file <path> authorized_keys file: one "<type> <base64> [comment]" per line,
|
|
4952
|
+
blank lines and # comments ignored (use - for stdin)
|
|
4953
|
+
--server <id> Target server ID or name (repeatable or comma separated)
|
|
4954
|
+
--all-servers Target every server in the organization
|
|
4955
|
+
--user <name> Server user of the keys (default: forge)
|
|
4956
|
+
--prune Also remove the keys of the user that are not in the file
|
|
4957
|
+
--dry-run Print the plan and change nothing
|
|
4958
|
+
-f, --format <fmt> Output format: json, human
|
|
4959
|
+
|
|
4960
|
+
${colors.bold("HOW IT WORKS:")}
|
|
4961
|
+
Each key gets the name "<comment slug>-<first 8 hex chars of SHA-256 of the key>".
|
|
4962
|
+
The Forge API does not return public keys, so keys are matched by name: a changed
|
|
4963
|
+
key gets a new name. The command lists the plan per server (+ add, - remove,
|
|
4964
|
+
= unchanged), applies it, then prints the result. Without --prune, keys are only
|
|
4965
|
+
added. Servers run one after the other; when an operation fails, the others still
|
|
4966
|
+
run and the command exits non-zero. When an add fails on a server, the removals on
|
|
4967
|
+
that server are skipped, so nobody loses access.
|
|
4968
|
+
|
|
4969
|
+
${colors.bold("JSON OUTPUT:")}
|
|
4970
|
+
{ "dry_run": bool, "servers": [{ id, name, added, removed, unchanged, errors }] }
|
|
4971
|
+
With --dry-run, "added" and "removed" hold the planned changes, not applied ones.
|
|
4972
|
+
|
|
4973
|
+
${colors.bold("EXAMPLES:")}
|
|
4974
|
+
forge ssh-keys sync --file .forge/authorized_keys --server 123 --dry-run
|
|
4975
|
+
forge ssh-keys sync --file .forge/authorized_keys --all-servers --prune
|
|
4976
|
+
cat keys.pub | forge ssh-keys sync --file - --server web-1,web-2 --user deploy
|
|
3902
4977
|
`);
|
|
3903
4978
|
else console.log(`
|
|
3904
4979
|
${colors.bold("forge ssh-keys")} - Manage SSH keys
|
|
@@ -3909,9 +4984,11 @@ ${colors.bold("USAGE:")}
|
|
|
3909
4984
|
${colors.bold("SUBCOMMANDS:")}
|
|
3910
4985
|
list, ls List SSH keys
|
|
3911
4986
|
get <id> Get SSH key details
|
|
4987
|
+
sync Make the SSH keys of servers match a file
|
|
3912
4988
|
|
|
3913
4989
|
${colors.bold("OPTIONS:")}
|
|
3914
|
-
--server <id> Server ID (required)
|
|
4990
|
+
--server <id> Server ID or name (required, except for sync --all-servers)
|
|
4991
|
+
--file <path> authorized_keys file, - for stdin (for sync)
|
|
3915
4992
|
-f, --format <fmt> Output format: json, human, table
|
|
3916
4993
|
|
|
3917
4994
|
Run ${colors.cyan("forge ssh-keys <subcommand> --help")} for details.
|
|
@@ -3961,6 +5038,26 @@ Run ${colors.cyan("forge user <subcommand> --help")} for details.
|
|
|
3961
5038
|
}
|
|
3962
5039
|
//#endregion
|
|
3963
5040
|
//#region src/utils/args.ts
|
|
5041
|
+
/**
|
|
5042
|
+
* Whether an argument can be the value of the option before it.
|
|
5043
|
+
* A lone "-" is a value (it means stdin), other "-" prefixed arguments are options.
|
|
5044
|
+
*/
|
|
5045
|
+
function isOptionValue(arg) {
|
|
5046
|
+
return arg === "-" || !arg.startsWith("-");
|
|
5047
|
+
}
|
|
5048
|
+
/**
|
|
5049
|
+
* Options that accept several values. Every other option keeps the last value.
|
|
5050
|
+
*/
|
|
5051
|
+
var REPEATABLE = /* @__PURE__ */ new Set(["server"]);
|
|
5052
|
+
/**
|
|
5053
|
+
* Set an option value. A repeatable option given more than once collects its string
|
|
5054
|
+
* values into an array (`--server 1 --server 2` → `["1", "2"]`).
|
|
5055
|
+
*/
|
|
5056
|
+
function setOption(options, key, value) {
|
|
5057
|
+
const previous = options[key];
|
|
5058
|
+
if (REPEATABLE.has(key) && typeof value === "string" && previous !== void 0 && typeof previous !== "boolean") options[key] = [previous, value].flat();
|
|
5059
|
+
else options[key] = value;
|
|
5060
|
+
}
|
|
3964
5061
|
function parseArgs(argv = process.argv.slice(2)) {
|
|
3965
5062
|
const options = {};
|
|
3966
5063
|
const positional = [];
|
|
@@ -3969,24 +5066,22 @@ function parseArgs(argv = process.argv.slice(2)) {
|
|
|
3969
5066
|
const arg = argv[i];
|
|
3970
5067
|
if (arg.startsWith("--")) {
|
|
3971
5068
|
const equalIndex = arg.indexOf("=");
|
|
3972
|
-
if (equalIndex > -1)
|
|
3973
|
-
|
|
3974
|
-
options[key] = arg.slice(equalIndex + 1);
|
|
3975
|
-
} else {
|
|
5069
|
+
if (equalIndex > -1) setOption(options, arg.slice(2, equalIndex), arg.slice(equalIndex + 1));
|
|
5070
|
+
else {
|
|
3976
5071
|
const key = arg.slice(2);
|
|
3977
5072
|
const nextArg = argv[i + 1];
|
|
3978
|
-
if (nextArg !== void 0 &&
|
|
3979
|
-
options
|
|
5073
|
+
if (nextArg !== void 0 && isOptionValue(nextArg)) {
|
|
5074
|
+
setOption(options, key, nextArg);
|
|
3980
5075
|
i++;
|
|
3981
|
-
} else options
|
|
5076
|
+
} else setOption(options, key, true);
|
|
3982
5077
|
}
|
|
3983
5078
|
} else if (arg.startsWith("-") && arg.length > 1) if (arg.length === 2) {
|
|
3984
5079
|
const key = arg[1];
|
|
3985
5080
|
const nextArg = argv[i + 1];
|
|
3986
|
-
if (nextArg !== void 0 &&
|
|
3987
|
-
options
|
|
5081
|
+
if (nextArg !== void 0 && isOptionValue(nextArg)) {
|
|
5082
|
+
setOption(options, key, nextArg);
|
|
3988
5083
|
i++;
|
|
3989
|
-
} else options
|
|
5084
|
+
} else setOption(options, key, true);
|
|
3990
5085
|
} else for (let j = 1; j < arg.length; j++) options[arg[j]] = true;
|
|
3991
5086
|
else if (command.length < 2) command.push(arg);
|
|
3992
5087
|
else positional.push(arg);
|
|
@@ -3997,9 +5092,17 @@ function parseArgs(argv = process.argv.slice(2)) {
|
|
|
3997
5092
|
positional
|
|
3998
5093
|
};
|
|
3999
5094
|
}
|
|
5095
|
+
/**
|
|
5096
|
+
* Whether the arguments ask for the CLI version: a bare `--version` or `-v`
|
|
5097
|
+
* with no command. Commands such as `php config` and `services restart` take
|
|
5098
|
+
* `--version <value>`, so a bare `--version` after a command is not a version request.
|
|
5099
|
+
*/
|
|
5100
|
+
function isVersionRequest({ command, options }) {
|
|
5101
|
+
return command.length === 0 && (options.version === true || options.v === true);
|
|
5102
|
+
}
|
|
4000
5103
|
//#endregion
|
|
4001
5104
|
//#region src/cli.ts
|
|
4002
|
-
var VERSION = "0.4.
|
|
5105
|
+
var VERSION = "0.4.7";
|
|
4003
5106
|
function showHelp() {
|
|
4004
5107
|
console.log(`
|
|
4005
5108
|
${colors.bold("forge")} v${VERSION}
|
|
@@ -4025,6 +5128,9 @@ ${colors.bold("COMMANDS:")}
|
|
|
4025
5128
|
deployments, d Manage deployments
|
|
4026
5129
|
list, ls List deployments (requires --server --site)
|
|
4027
5130
|
deploy Trigger a deployment (requires --server --site)
|
|
5131
|
+
logs [id] Show deployment output (requires --server --site)
|
|
5132
|
+
script Show the deployment script (requires --server --site)
|
|
5133
|
+
update-script Update the deployment script (requires --server --site --file|--content) [--check]
|
|
4028
5134
|
|
|
4029
5135
|
databases, db Manage databases
|
|
4030
5136
|
list, ls List databases (requires --server)
|
|
@@ -4043,11 +5149,11 @@ ${colors.bold("COMMANDS:")}
|
|
|
4043
5149
|
|
|
4044
5150
|
env Manage environment variables
|
|
4045
5151
|
get Get env variables (requires --server --site)
|
|
4046
|
-
update Update env variables (requires --server --site --content)
|
|
5152
|
+
update Update env variables (requires --server --site --file|--content) [--check]
|
|
4047
5153
|
|
|
4048
5154
|
nginx Manage nginx configuration
|
|
4049
5155
|
get Get nginx config (requires --server --site)
|
|
4050
|
-
update Update nginx config (requires --server --site --content)
|
|
5156
|
+
update Update nginx config (requires --server --site --file|--content) [--check]
|
|
4051
5157
|
|
|
4052
5158
|
certificates, certs Manage SSL certificates (per-domain in v2)
|
|
4053
5159
|
get Get certificate for a domain (requires --server --site --domain)
|
|
@@ -4064,6 +5170,7 @@ ${colors.bold("COMMANDS:")}
|
|
|
4064
5170
|
ssh-keys Manage SSH keys
|
|
4065
5171
|
list, ls List SSH keys (requires --server)
|
|
4066
5172
|
get <id> Get SSH key details (requires --server)
|
|
5173
|
+
sync Make server SSH keys match a file (requires --file, --server|--all-servers)
|
|
4067
5174
|
|
|
4068
5175
|
backups Manage backup configurations
|
|
4069
5176
|
list, ls List backup configurations (requires --server)
|
|
@@ -4086,6 +5193,20 @@ ${colors.bold("COMMANDS:")}
|
|
|
4086
5193
|
list, ls List services on a server (forge services list <server>)
|
|
4087
5194
|
restart <service> Restart a service (forge services restart <server> <service>)
|
|
4088
5195
|
|
|
5196
|
+
php Manage PHP versions, configuration, limits and OPcache
|
|
5197
|
+
list, ls List installed PHP versions (requires --server)
|
|
5198
|
+
config Show a configuration file (requires --server --version --type)
|
|
5199
|
+
update-config Replace a configuration file (requires --server --version --type --file|--content) [--check]
|
|
5200
|
+
max-upload-size Show the max upload size (requires --server)
|
|
5201
|
+
set-max-upload-size <mb>
|
|
5202
|
+
Set the max upload size (requires --server)
|
|
5203
|
+
max-execution-time Show the max execution time (requires --server)
|
|
5204
|
+
set-max-execution-time <seconds>
|
|
5205
|
+
Set the max execution time (requires --server)
|
|
5206
|
+
opcache Show the OPcache status (requires --server)
|
|
5207
|
+
enable-opcache Enable OPcache (requires --server)
|
|
5208
|
+
disable-opcache Disable OPcache (requires --server)
|
|
5209
|
+
|
|
4089
5210
|
user Display authenticated user profile
|
|
4090
5211
|
get Get the authenticated user profile
|
|
4091
5212
|
|
|
@@ -4167,14 +5288,15 @@ ${colors.bold("ENVIRONMENT VARIABLES:")}
|
|
|
4167
5288
|
`);
|
|
4168
5289
|
}
|
|
4169
5290
|
async function main() {
|
|
4170
|
-
const
|
|
5291
|
+
const args = parseArgs();
|
|
5292
|
+
const { command, options, positional } = args;
|
|
4171
5293
|
const [mainCommand, subcommand] = command;
|
|
4172
5294
|
const wantsHelp = options.help !== void 0 || options.h !== void 0;
|
|
4173
5295
|
if (wantsHelp && !mainCommand) {
|
|
4174
5296
|
showHelp();
|
|
4175
5297
|
process.exit(0);
|
|
4176
5298
|
}
|
|
4177
|
-
if (
|
|
5299
|
+
if (isVersionRequest(args)) {
|
|
4178
5300
|
console.log(VERSION);
|
|
4179
5301
|
process.exit(0);
|
|
4180
5302
|
}
|
|
@@ -4309,6 +5431,13 @@ async function main() {
|
|
|
4309
5431
|
}
|
|
4310
5432
|
await handleServicesCommand(subcommand ?? "list", positional, options);
|
|
4311
5433
|
break;
|
|
5434
|
+
case "php":
|
|
5435
|
+
if (wantsHelp) {
|
|
5436
|
+
showPhpHelp(subcommand);
|
|
5437
|
+
process.exit(0);
|
|
5438
|
+
}
|
|
5439
|
+
await handlePhpCommand(subcommand ?? "list", positional, options);
|
|
5440
|
+
break;
|
|
4312
5441
|
case "user":
|
|
4313
5442
|
if (wantsHelp) {
|
|
4314
5443
|
showUserHelp(subcommand);
|