@studiometa/forge-cli 0.4.6 → 0.4.8
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 +1194 -53
- 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/print-content.d.ts +9 -0
- package/dist/utils/print-content.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,145 @@ 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
|
|
2061
|
+
//#region src/utils/print-content.ts
|
|
2062
|
+
/**
|
|
2063
|
+
* Print file content fetched from Forge, such as a configuration or a script.
|
|
2064
|
+
*/
|
|
2065
|
+
/**
|
|
2066
|
+
* Write the content as it is, with a newline only when it does not end with one,
|
|
2067
|
+
* so `forge <command> get > file` round-trips with `--file file --check` without drift.
|
|
2068
|
+
*/
|
|
2069
|
+
function printContent(content) {
|
|
2070
|
+
process.stdout.write(content.endsWith("\n") ? content : `${content}\n`);
|
|
2071
|
+
}
|
|
2072
|
+
//#endregion
|
|
1866
2073
|
//#region src/commands/deployments/handlers.ts
|
|
1867
2074
|
async function deploymentsList(ctx) {
|
|
1868
2075
|
const server = String(ctx.options.server ?? "");
|
|
@@ -1917,17 +2124,81 @@ async function deploymentsLogs(args, ctx) {
|
|
|
1917
2124
|
else ctx.formatter.info("No log output available.");
|
|
1918
2125
|
}, ctx.formatter);
|
|
1919
2126
|
}
|
|
2127
|
+
async function deploymentsScript(ctx) {
|
|
2128
|
+
const server = String(ctx.options.server ?? "");
|
|
2129
|
+
const site = String(ctx.options.site ?? "");
|
|
2130
|
+
if (!server) exitWithValidationError("server_id", "forge deployments script --server <server_id> --site <site_id>", ctx.formatter);
|
|
2131
|
+
if (!site) exitWithValidationError("site_id", "forge deployments script --server <server_id> --site <site_id>", ctx.formatter);
|
|
2132
|
+
await runCommand(async () => {
|
|
2133
|
+
const token = ctx.getToken();
|
|
2134
|
+
const execCtx = ctx.createExecutorContext(token);
|
|
2135
|
+
const server_id = await resolveServerId(server, execCtx);
|
|
2136
|
+
const result = await getDeploymentScript({
|
|
2137
|
+
server_id,
|
|
2138
|
+
site_id: await resolveSiteId(site, server_id, execCtx)
|
|
2139
|
+
}, execCtx);
|
|
2140
|
+
if (ctx.formatter.isJson()) console.log(JSON.stringify({ content: result.data }));
|
|
2141
|
+
else printContent(result.data);
|
|
2142
|
+
}, ctx.formatter);
|
|
2143
|
+
}
|
|
2144
|
+
async function deploymentsUpdateScript(ctx) {
|
|
2145
|
+
const usage = "forge deployments update-script --server <server_id> --site <site_id> (--file <path|-> | --content <script>) [--check]";
|
|
2146
|
+
const server = String(ctx.options.server ?? "");
|
|
2147
|
+
const site = String(ctx.options.site ?? "");
|
|
2148
|
+
const check = Boolean(ctx.options.check);
|
|
2149
|
+
if (!server) exitWithValidationError("server_id", usage, ctx.formatter);
|
|
2150
|
+
if (!site) exitWithValidationError("site_id", usage, ctx.formatter);
|
|
2151
|
+
const script = readContentOption(ctx.options, {
|
|
2152
|
+
label: "deployment script",
|
|
2153
|
+
usage,
|
|
2154
|
+
formatter: ctx.formatter
|
|
2155
|
+
});
|
|
2156
|
+
await runCommand(async () => {
|
|
2157
|
+
const token = ctx.getToken();
|
|
2158
|
+
const execCtx = ctx.createExecutorContext(token);
|
|
2159
|
+
const server_id = await resolveServerId(server, execCtx);
|
|
2160
|
+
const site_id = await resolveSiteId(site, server_id, execCtx);
|
|
2161
|
+
if (check) {
|
|
2162
|
+
reportTextDrift("deployment script", (await getDeploymentScript({
|
|
2163
|
+
server_id,
|
|
2164
|
+
site_id
|
|
2165
|
+
}, execCtx)).data, script, ctx.formatter);
|
|
2166
|
+
return;
|
|
2167
|
+
}
|
|
2168
|
+
await updateDeploymentScript({
|
|
2169
|
+
server_id,
|
|
2170
|
+
site_id,
|
|
2171
|
+
content: script
|
|
2172
|
+
}, execCtx);
|
|
2173
|
+
ctx.formatter.success("Deployment script updated.");
|
|
2174
|
+
}, ctx.formatter);
|
|
2175
|
+
}
|
|
1920
2176
|
async function deploymentsDeploy(ctx) {
|
|
2177
|
+
const usage = "forge deployments deploy --server <server_id> --site <site_id> [--script-file <path>]";
|
|
1921
2178
|
const server = String(ctx.options.server ?? "");
|
|
1922
2179
|
const site = String(ctx.options.site ?? "");
|
|
1923
2180
|
const streamLogs = Boolean(ctx.options.stream);
|
|
1924
|
-
|
|
1925
|
-
if (!
|
|
2181
|
+
const scriptFile = ctx.options["script-file"];
|
|
2182
|
+
if (!server) exitWithValidationError("server_id", usage, ctx.formatter);
|
|
2183
|
+
if (!site) exitWithValidationError("site_id", usage, ctx.formatter);
|
|
2184
|
+
const script = scriptFile === void 0 ? void 0 : readContentFile(scriptFile, "script-file", {
|
|
2185
|
+
label: "deployment script",
|
|
2186
|
+
usage,
|
|
2187
|
+
formatter: ctx.formatter
|
|
2188
|
+
});
|
|
1926
2189
|
await runCommand(async () => {
|
|
1927
2190
|
const token = ctx.getToken();
|
|
1928
2191
|
const execCtx = ctx.createExecutorContext(token);
|
|
1929
2192
|
const server_id = await resolveServerId(server, execCtx);
|
|
1930
2193
|
const site_id = await resolveSiteId(site, server_id, execCtx);
|
|
2194
|
+
if (script !== void 0) {
|
|
2195
|
+
await updateDeploymentScript({
|
|
2196
|
+
server_id,
|
|
2197
|
+
site_id,
|
|
2198
|
+
content: script
|
|
2199
|
+
}, execCtx);
|
|
2200
|
+
process.stderr.write("Deployment script updated.\n");
|
|
2201
|
+
}
|
|
1931
2202
|
const result = await deploySiteAndWait({
|
|
1932
2203
|
server_id,
|
|
1933
2204
|
site_id,
|
|
@@ -1955,9 +2226,11 @@ var handleDeploymentsCommand = createCommandRouter({
|
|
|
1955
2226
|
ls: deploymentsList,
|
|
1956
2227
|
deploy: deploymentsDeploy,
|
|
1957
2228
|
logs: [deploymentsLogs, "args"],
|
|
1958
|
-
log: [deploymentsLogs, "args"]
|
|
2229
|
+
log: [deploymentsLogs, "args"],
|
|
2230
|
+
script: deploymentsScript,
|
|
2231
|
+
"update-script": deploymentsUpdateScript
|
|
1959
2232
|
},
|
|
1960
|
-
writeSubcommands: ["deploy"]
|
|
2233
|
+
writeSubcommands: ["deploy", "update-script"]
|
|
1961
2234
|
});
|
|
1962
2235
|
//#endregion
|
|
1963
2236
|
//#region src/commands/deployments/help.ts
|
|
@@ -1983,6 +2256,44 @@ ${colors.bold("OPTIONS:")}
|
|
|
1983
2256
|
--server <id> Server ID (required)
|
|
1984
2257
|
--site <id> Site ID (required)
|
|
1985
2258
|
-f, --format <fmt> Output format: json, human, table
|
|
2259
|
+
`);
|
|
2260
|
+
else if (subcommand === "script") console.log(`
|
|
2261
|
+
${colors.bold("forge deployments script")} - Show the deployment script of a site
|
|
2262
|
+
|
|
2263
|
+
${colors.bold("USAGE:")}
|
|
2264
|
+
forge deployments script --server <server_id> --site <site_id>
|
|
2265
|
+
|
|
2266
|
+
${colors.bold("OPTIONS:")}
|
|
2267
|
+
--server <id> Server ID (required)
|
|
2268
|
+
--site <id> Site ID (required)
|
|
2269
|
+
-f, --format <fmt> Output format: json ({ content }), human (raw script)
|
|
2270
|
+
|
|
2271
|
+
${colors.bold("EXAMPLES:")}
|
|
2272
|
+
forge deployments script --server 123 --site 456 > deploy.sh
|
|
2273
|
+
`);
|
|
2274
|
+
else if (subcommand === "update-script") console.log(`
|
|
2275
|
+
${colors.bold("forge deployments update-script")} - Update the deployment script of a site
|
|
2276
|
+
|
|
2277
|
+
${colors.bold("USAGE:")}
|
|
2278
|
+
forge deployments update-script --server <server_id> --site <site_id> --file <path>
|
|
2279
|
+
forge deployments update-script --server <server_id> --site <site_id> --content <script>
|
|
2280
|
+
forge deployments update-script --server <server_id> --site <site_id> --file <path> --check
|
|
2281
|
+
|
|
2282
|
+
${colors.bold("OPTIONS:")}
|
|
2283
|
+
--server <id> Server ID (required)
|
|
2284
|
+
--site <id> Site ID (required)
|
|
2285
|
+
--file <path> Read the script from a file (use - for stdin)
|
|
2286
|
+
--content <str> Script content
|
|
2287
|
+
--check Compare with the content on Forge and do not write.
|
|
2288
|
+
Exit 0 when in sync, 1 with a line diff (- remote, + local).
|
|
2289
|
+
-f, --format <fmt> Output format: json ({ inSync, diff? } with --check), human
|
|
2290
|
+
|
|
2291
|
+
Give exactly one of --file or --content. An empty script is rejected.
|
|
2292
|
+
|
|
2293
|
+
${colors.bold("EXAMPLES:")}
|
|
2294
|
+
forge deployments update-script --server 123 --site 456 --file deploy.sh
|
|
2295
|
+
cat deploy.sh | forge deployments update-script --server 123 --site 456 --file -
|
|
2296
|
+
forge deployments update-script --server 123 --site 456 --file deploy.sh --check
|
|
1986
2297
|
`);
|
|
1987
2298
|
else if (subcommand === "deploy") console.log(`
|
|
1988
2299
|
${colors.bold("forge deployments deploy")} - Trigger a deployment
|
|
@@ -1991,10 +2302,12 @@ ${colors.bold("USAGE:")}
|
|
|
1991
2302
|
forge deployments deploy --server <server_id> --site <site_id> [options]
|
|
1992
2303
|
|
|
1993
2304
|
${colors.bold("OPTIONS:")}
|
|
1994
|
-
--server <id>
|
|
1995
|
-
--site <id>
|
|
1996
|
-
--
|
|
1997
|
-
|
|
2305
|
+
--server <id> Server ID (required)
|
|
2306
|
+
--site <id> Site ID (required)
|
|
2307
|
+
--script-file <path> Upload this deployment script before deploying (use - for stdin).
|
|
2308
|
+
If the upload fails, the deployment does not start.
|
|
2309
|
+
--stream Stream deployment logs in real-time (default: show progress)
|
|
2310
|
+
-f, --format <fmt> Output format: json, human, table
|
|
1998
2311
|
`);
|
|
1999
2312
|
else console.log(`
|
|
2000
2313
|
${colors.bold("forge deployments")} - Manage deployments
|
|
@@ -2009,16 +2322,95 @@ ${colors.bold("SUBCOMMANDS:")}
|
|
|
2009
2322
|
list, ls List deployments for a site
|
|
2010
2323
|
deploy Trigger a deployment
|
|
2011
2324
|
logs Show output of a deployment (latest if no id)
|
|
2325
|
+
script Show the deployment script
|
|
2326
|
+
update-script Update the deployment script from a file, stdin or a string
|
|
2012
2327
|
|
|
2013
2328
|
${colors.bold("OPTIONS:")}
|
|
2014
|
-
--server <id>
|
|
2015
|
-
--site <id>
|
|
2016
|
-
|
|
2329
|
+
--server <id> Server ID (required)
|
|
2330
|
+
--site <id> Site ID (required)
|
|
2331
|
+
--file <path> Script file, - for stdin (for update-script)
|
|
2332
|
+
--content <str> Script content (for update-script)
|
|
2333
|
+
--check Compare with Forge, do not write (for update-script)
|
|
2334
|
+
--script-file <path> Upload this script before deploying (for deploy)
|
|
2335
|
+
-f, --format <fmt> Output format: json, human, table
|
|
2017
2336
|
|
|
2018
2337
|
Run ${colors.cyan("forge deployments <subcommand> --help")} for details.
|
|
2019
2338
|
`);
|
|
2020
2339
|
}
|
|
2021
2340
|
//#endregion
|
|
2341
|
+
//#region src/commands/env/env-diff.ts
|
|
2342
|
+
var NAME = /^[A-Za-z_][A-Za-z0-9_.]*$/;
|
|
2343
|
+
/**
|
|
2344
|
+
* Find the closing quote of a quoted value. `\` escapes a character in double quotes only.
|
|
2345
|
+
* Returns -1 when the value is not closed.
|
|
2346
|
+
*/
|
|
2347
|
+
function closingQuote(value, quote) {
|
|
2348
|
+
for (let index = 1; index < value.length; index++) if (quote === "\"" && value[index] === "\\") index++;
|
|
2349
|
+
else if (value[index] === quote) return index;
|
|
2350
|
+
return -1;
|
|
2351
|
+
}
|
|
2352
|
+
/**
|
|
2353
|
+
* Parse a .env file into a map of name to value, with the dotenv rules used by Laravel:
|
|
2354
|
+
* an optional `export ` prefix, quoted values that can span lines, and ` #` inline comments
|
|
2355
|
+
* after unquoted values. Blank lines, comments and lines without a valid name are ignored,
|
|
2356
|
+
* so a part of a value can never show as a name.
|
|
2357
|
+
*/
|
|
2358
|
+
function parseEnv(content) {
|
|
2359
|
+
const variables = /* @__PURE__ */ new Map();
|
|
2360
|
+
const lines = content.split(/\r?\n/);
|
|
2361
|
+
for (let index = 0; index < lines.length; index++) {
|
|
2362
|
+
const line = lines[index].trim();
|
|
2363
|
+
const separator = line.indexOf("=");
|
|
2364
|
+
const name = line.slice(0, Math.max(separator, 0)).trim().replace(/^export\s+/, "");
|
|
2365
|
+
if (!NAME.test(name)) continue;
|
|
2366
|
+
let value = line.slice(separator + 1).trim();
|
|
2367
|
+
const quote = value[0];
|
|
2368
|
+
if (quote === "\"" || quote === "'") {
|
|
2369
|
+
let end = closingQuote(value, quote);
|
|
2370
|
+
while (end === -1 && index + 1 < lines.length) {
|
|
2371
|
+
index++;
|
|
2372
|
+
value += `\n${lines[index]}`;
|
|
2373
|
+
end = closingQuote(value, quote);
|
|
2374
|
+
}
|
|
2375
|
+
value = value.slice(1, end === -1 ? void 0 : end);
|
|
2376
|
+
} else value = value.replace(/\s+#.*$/, "");
|
|
2377
|
+
variables.set(name, value);
|
|
2378
|
+
}
|
|
2379
|
+
return variables;
|
|
2380
|
+
}
|
|
2381
|
+
/**
|
|
2382
|
+
* Compare the variables of two .env files. Returns only variable names.
|
|
2383
|
+
*/
|
|
2384
|
+
function diffEnv(remote, local) {
|
|
2385
|
+
const remoteVars = parseEnv(remote);
|
|
2386
|
+
const localVars = parseEnv(local);
|
|
2387
|
+
const result = {
|
|
2388
|
+
added: [],
|
|
2389
|
+
removed: [],
|
|
2390
|
+
changed: []
|
|
2391
|
+
};
|
|
2392
|
+
for (const [name, value] of localVars) if (!remoteVars.has(name)) result.added.push(name);
|
|
2393
|
+
else if (remoteVars.get(name) !== value) result.changed.push(name);
|
|
2394
|
+
for (const name of remoteVars.keys()) if (!localVars.has(name)) result.removed.push(name);
|
|
2395
|
+
return result;
|
|
2396
|
+
}
|
|
2397
|
+
/**
|
|
2398
|
+
* Turn an env diff into a drift report, or null when the variables are the same.
|
|
2399
|
+
* The report holds variable names only, never values, because CI logs are often shared.
|
|
2400
|
+
*/
|
|
2401
|
+
function envDrift(remote, local) {
|
|
2402
|
+
const result = diffEnv(remote, local);
|
|
2403
|
+
const lines = [
|
|
2404
|
+
...result.added.map((name) => `+ ${name} (added)`),
|
|
2405
|
+
...result.removed.map((name) => `- ${name} (removed)`),
|
|
2406
|
+
...result.changed.map((name) => `~ ${name} (changed)`)
|
|
2407
|
+
];
|
|
2408
|
+
return lines.length > 0 ? {
|
|
2409
|
+
text: lines.join("\n"),
|
|
2410
|
+
json: result
|
|
2411
|
+
} : null;
|
|
2412
|
+
}
|
|
2413
|
+
//#endregion
|
|
2022
2414
|
//#region src/commands/env/handlers.ts
|
|
2023
2415
|
async function envGet(ctx) {
|
|
2024
2416
|
const server = String(ctx.options.server ?? "");
|
|
@@ -2034,23 +2426,36 @@ async function envGet(ctx) {
|
|
|
2034
2426
|
site_id: await resolveSiteId(site, server_id, execCtx)
|
|
2035
2427
|
}, execCtx);
|
|
2036
2428
|
if (ctx.formatter.isJson()) console.log(JSON.stringify({ content: result.data }));
|
|
2037
|
-
else
|
|
2429
|
+
else printContent(result.data);
|
|
2038
2430
|
}, ctx.formatter);
|
|
2039
2431
|
}
|
|
2040
2432
|
async function envUpdate(ctx) {
|
|
2433
|
+
const usage = "forge env update --server <server_id> --site <site_id> (--file <path|-> | --content <content>) [--check]";
|
|
2041
2434
|
const server = String(ctx.options.server ?? "");
|
|
2042
2435
|
const site = String(ctx.options.site ?? "");
|
|
2043
|
-
const
|
|
2044
|
-
if (!server) exitWithValidationError("server_id",
|
|
2045
|
-
if (!site) exitWithValidationError("site_id",
|
|
2046
|
-
|
|
2436
|
+
const check = Boolean(ctx.options.check);
|
|
2437
|
+
if (!server) exitWithValidationError("server_id", usage, ctx.formatter);
|
|
2438
|
+
if (!site) exitWithValidationError("site_id", usage, ctx.formatter);
|
|
2439
|
+
const content = readContentOption(ctx.options, {
|
|
2440
|
+
label: "environment file",
|
|
2441
|
+
usage,
|
|
2442
|
+
formatter: ctx.formatter
|
|
2443
|
+
});
|
|
2047
2444
|
await runCommand(async () => {
|
|
2048
2445
|
const token = ctx.getToken();
|
|
2049
2446
|
const execCtx = ctx.createExecutorContext(token);
|
|
2050
2447
|
const server_id = await resolveServerId(server, execCtx);
|
|
2448
|
+
const site_id = await resolveSiteId(site, server_id, execCtx);
|
|
2449
|
+
if (check) {
|
|
2450
|
+
reportDrift("environment file", envDrift((await getEnv({
|
|
2451
|
+
server_id,
|
|
2452
|
+
site_id
|
|
2453
|
+
}, execCtx)).data, content), ctx.formatter);
|
|
2454
|
+
return;
|
|
2455
|
+
}
|
|
2051
2456
|
await updateEnv({
|
|
2052
2457
|
server_id,
|
|
2053
|
-
site_id
|
|
2458
|
+
site_id,
|
|
2054
2459
|
content
|
|
2055
2460
|
}, execCtx);
|
|
2056
2461
|
ctx.formatter.success("Environment variables updated.");
|
|
@@ -2079,7 +2484,26 @@ ${colors.bold("USAGE:")}
|
|
|
2079
2484
|
${colors.bold("forge env update")} - Update environment variables
|
|
2080
2485
|
|
|
2081
2486
|
${colors.bold("USAGE:")}
|
|
2487
|
+
forge env update --server <server_id> --site <site_id> --file <path>
|
|
2488
|
+
forge env update --server <server_id> --site <site_id> --file <path> --check
|
|
2082
2489
|
forge env update --server <server_id> --site <site_id> --content <env_content>
|
|
2490
|
+
|
|
2491
|
+
${colors.bold("OPTIONS:")}
|
|
2492
|
+
--server <id> Server ID (required)
|
|
2493
|
+
--site <id> Site ID (required)
|
|
2494
|
+
--file <path> Read the content from a file (use - for stdin)
|
|
2495
|
+
--content <str> New env content
|
|
2496
|
+
--check Compare with the .env on Forge and do not write.
|
|
2497
|
+
Exit 0 when in sync, 1 when different. Prints only variable
|
|
2498
|
+
names (added, removed, changed), never values.
|
|
2499
|
+
-f, --format <fmt> Output format: json ({ inSync, diff? } with --check), human
|
|
2500
|
+
|
|
2501
|
+
Give exactly one of --file or --content. Empty content is rejected.
|
|
2502
|
+
|
|
2503
|
+
${colors.bold("EXAMPLES:")}
|
|
2504
|
+
forge env update --server 123 --site 456 --file .env
|
|
2505
|
+
cat .env | forge env update --server 123 --site 456 --file -
|
|
2506
|
+
forge env update --server 123 --site 456 --file .env --check
|
|
2083
2507
|
`);
|
|
2084
2508
|
else console.log(`
|
|
2085
2509
|
${colors.bold("forge env")} - Manage environment variables
|
|
@@ -2094,7 +2518,9 @@ ${colors.bold("SUBCOMMANDS:")}
|
|
|
2094
2518
|
${colors.bold("OPTIONS:")}
|
|
2095
2519
|
--server <id> Server ID (required)
|
|
2096
2520
|
--site <id> Site ID (required)
|
|
2521
|
+
--file <path> Content file, - for stdin (for update)
|
|
2097
2522
|
--content <str> New env content (for update)
|
|
2523
|
+
--check Compare with Forge, do not write (for update)
|
|
2098
2524
|
-f, --format <fmt> Output format: json, human
|
|
2099
2525
|
|
|
2100
2526
|
Run ${colors.cyan("forge env <subcommand> --help")} for details.
|
|
@@ -2361,23 +2787,36 @@ async function nginxGet(ctx) {
|
|
|
2361
2787
|
site_id: await resolveSiteId(site, server_id, execCtx)
|
|
2362
2788
|
}, execCtx);
|
|
2363
2789
|
if (ctx.formatter.isJson()) console.log(JSON.stringify({ content: result.data }));
|
|
2364
|
-
else
|
|
2790
|
+
else printContent(result.data);
|
|
2365
2791
|
}, ctx.formatter);
|
|
2366
2792
|
}
|
|
2367
2793
|
async function nginxUpdate(ctx) {
|
|
2794
|
+
const usage = "forge nginx update --server <server_id> --site <site_id> (--file <path|-> | --content <content>) [--check]";
|
|
2368
2795
|
const server = String(ctx.options.server ?? "");
|
|
2369
2796
|
const site = String(ctx.options.site ?? "");
|
|
2370
|
-
const
|
|
2371
|
-
if (!server) exitWithValidationError("server_id",
|
|
2372
|
-
if (!site) exitWithValidationError("site_id",
|
|
2373
|
-
|
|
2797
|
+
const check = Boolean(ctx.options.check);
|
|
2798
|
+
if (!server) exitWithValidationError("server_id", usage, ctx.formatter);
|
|
2799
|
+
if (!site) exitWithValidationError("site_id", usage, ctx.formatter);
|
|
2800
|
+
const content = readContentOption(ctx.options, {
|
|
2801
|
+
label: "nginx configuration",
|
|
2802
|
+
usage,
|
|
2803
|
+
formatter: ctx.formatter
|
|
2804
|
+
});
|
|
2374
2805
|
await runCommand(async () => {
|
|
2375
2806
|
const token = ctx.getToken();
|
|
2376
2807
|
const execCtx = ctx.createExecutorContext(token);
|
|
2377
2808
|
const server_id = await resolveServerId(server, execCtx);
|
|
2809
|
+
const site_id = await resolveSiteId(site, server_id, execCtx);
|
|
2810
|
+
if (check) {
|
|
2811
|
+
reportTextDrift("nginx configuration", (await getNginxConfig({
|
|
2812
|
+
server_id,
|
|
2813
|
+
site_id
|
|
2814
|
+
}, execCtx)).data, content, ctx.formatter);
|
|
2815
|
+
return;
|
|
2816
|
+
}
|
|
2378
2817
|
await updateNginxConfig({
|
|
2379
2818
|
server_id,
|
|
2380
|
-
site_id
|
|
2819
|
+
site_id,
|
|
2381
2820
|
content
|
|
2382
2821
|
}, execCtx);
|
|
2383
2822
|
ctx.formatter.success("Nginx configuration updated.");
|
|
@@ -2406,7 +2845,25 @@ ${colors.bold("USAGE:")}
|
|
|
2406
2845
|
${colors.bold("forge nginx update")} - Update nginx configuration
|
|
2407
2846
|
|
|
2408
2847
|
${colors.bold("USAGE:")}
|
|
2848
|
+
forge nginx update --server <server_id> --site <site_id> --file <path>
|
|
2849
|
+
forge nginx update --server <server_id> --site <site_id> --file <path> --check
|
|
2409
2850
|
forge nginx update --server <server_id> --site <site_id> --content <nginx_config>
|
|
2851
|
+
|
|
2852
|
+
${colors.bold("OPTIONS:")}
|
|
2853
|
+
--server <id> Server ID (required)
|
|
2854
|
+
--site <id> Site ID (required)
|
|
2855
|
+
--file <path> Read the content from a file (use - for stdin)
|
|
2856
|
+
--content <str> New nginx config
|
|
2857
|
+
--check Compare with the content on Forge and do not write.
|
|
2858
|
+
Exit 0 when in sync, 1 with a line diff (- remote, + local).
|
|
2859
|
+
-f, --format <fmt> Output format: json ({ inSync, diff? } with --check), human
|
|
2860
|
+
|
|
2861
|
+
Give exactly one of --file or --content. Empty content is rejected.
|
|
2862
|
+
|
|
2863
|
+
${colors.bold("EXAMPLES:")}
|
|
2864
|
+
forge nginx update --server 123 --site 456 --file nginx.conf
|
|
2865
|
+
cat nginx.conf | forge nginx update --server 123 --site 456 --file -
|
|
2866
|
+
forge nginx update --server 123 --site 456 --file nginx.conf --check
|
|
2410
2867
|
`);
|
|
2411
2868
|
else console.log(`
|
|
2412
2869
|
${colors.bold("forge nginx")} - Manage nginx configuration
|
|
@@ -2421,7 +2878,9 @@ ${colors.bold("SUBCOMMANDS:")}
|
|
|
2421
2878
|
${colors.bold("OPTIONS:")}
|
|
2422
2879
|
--server <id> Server ID (required)
|
|
2423
2880
|
--site <id> Site ID (required)
|
|
2881
|
+
--file <path> Content file, - for stdin (for update)
|
|
2424
2882
|
--content <str> New nginx config (for update)
|
|
2883
|
+
--check Compare with Forge, do not write (for update)
|
|
2425
2884
|
|
|
2426
2885
|
Run ${colors.cyan("forge nginx <subcommand> --help")} for details.
|
|
2427
2886
|
`);
|
|
@@ -2611,6 +3070,328 @@ Run ${colors.cyan("forge nginx-templates <subcommand> --help")} for details.
|
|
|
2611
3070
|
`);
|
|
2612
3071
|
}
|
|
2613
3072
|
//#endregion
|
|
3073
|
+
//#region src/commands/php/handlers.ts
|
|
3074
|
+
var CONFIG_USAGE = "forge php config --server <server_id> --version <version> --type <cli|fpm|pool> [--user <user>]";
|
|
3075
|
+
var UPDATE_CONFIG_USAGE = "forge php update-config --server <server_id> --version <version> --type <cli|fpm|pool> (--file <path|-> | --content <content>) [--user <user>] [--check]";
|
|
3076
|
+
/**
|
|
3077
|
+
* Return the --server option, or exit with a validation error when it is missing.
|
|
3078
|
+
*/
|
|
3079
|
+
function requireServer(ctx, usage) {
|
|
3080
|
+
const server = String(ctx.options.server ?? "");
|
|
3081
|
+
if (!server) exitWithValidationError("server_id", usage, ctx.formatter);
|
|
3082
|
+
return server;
|
|
3083
|
+
}
|
|
3084
|
+
/**
|
|
3085
|
+
* Resolve the server and run `fn` with its ID, with the CLI error handling.
|
|
3086
|
+
*/
|
|
3087
|
+
async function withServer(ctx, server, fn) {
|
|
3088
|
+
await runCommand(async () => {
|
|
3089
|
+
const execCtx = ctx.createExecutorContext(ctx.getToken());
|
|
3090
|
+
await fn(await resolveServerId(server, execCtx), execCtx);
|
|
3091
|
+
}, ctx.formatter);
|
|
3092
|
+
}
|
|
3093
|
+
/**
|
|
3094
|
+
* Read --version, --type and --user for the config commands.
|
|
3095
|
+
* Exits with a validation error when --version is missing, --type is not cli, fpm or pool,
|
|
3096
|
+
* or --user is given for a type other than pool.
|
|
3097
|
+
*/
|
|
3098
|
+
function requireConfigTarget(ctx, usage) {
|
|
3099
|
+
const { version, type, user } = ctx.options;
|
|
3100
|
+
if (typeof version !== "string" || !version) exitWithValidationError("version", usage, ctx.formatter);
|
|
3101
|
+
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);
|
|
3102
|
+
if (typeof user === "string" && user && type !== "pool") exitWithError(new ValidationError(`--user applies to --type pool only, not "${type}"`, "user", [`Usage: ${usage}`]), ctx.formatter);
|
|
3103
|
+
return {
|
|
3104
|
+
version,
|
|
3105
|
+
type,
|
|
3106
|
+
user: typeof user === "string" ? user : void 0
|
|
3107
|
+
};
|
|
3108
|
+
}
|
|
3109
|
+
/**
|
|
3110
|
+
* Parse a whole number argument, or exit with a validation error.
|
|
3111
|
+
*/
|
|
3112
|
+
function requireWholeNumber(value, field, usage, ctx) {
|
|
3113
|
+
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);
|
|
3114
|
+
return Number(value);
|
|
3115
|
+
}
|
|
3116
|
+
/**
|
|
3117
|
+
* List the PHP versions installed on a server.
|
|
3118
|
+
*/
|
|
3119
|
+
async function phpList(ctx) {
|
|
3120
|
+
await withServer(ctx, requireServer(ctx, "forge php list --server <server_id>"), async (server_id, execCtx) => {
|
|
3121
|
+
const result = await listPhpVersions({ server_id }, execCtx);
|
|
3122
|
+
ctx.formatter.outputList(result.data, [
|
|
3123
|
+
"id",
|
|
3124
|
+
"version",
|
|
3125
|
+
"binary_name",
|
|
3126
|
+
"status"
|
|
3127
|
+
], "No PHP versions installed.", (v) => `${String(v.id).padEnd(8)} ${v.version.padEnd(6)} ${v.binary_name.padEnd(10)} ${v.status}`);
|
|
3128
|
+
});
|
|
3129
|
+
}
|
|
3130
|
+
/**
|
|
3131
|
+
* Print a PHP configuration file (cli, fpm or pool).
|
|
3132
|
+
*/
|
|
3133
|
+
async function phpConfig(ctx) {
|
|
3134
|
+
const server = requireServer(ctx, CONFIG_USAGE);
|
|
3135
|
+
const target = requireConfigTarget(ctx, CONFIG_USAGE);
|
|
3136
|
+
await withServer(ctx, server, async (server_id, execCtx) => {
|
|
3137
|
+
const result = await getPhpConfig({
|
|
3138
|
+
server_id,
|
|
3139
|
+
...target
|
|
3140
|
+
}, execCtx);
|
|
3141
|
+
if (ctx.formatter.isJson()) console.log(JSON.stringify({ content: result.data }));
|
|
3142
|
+
else printContent(result.data);
|
|
3143
|
+
});
|
|
3144
|
+
}
|
|
3145
|
+
/**
|
|
3146
|
+
* Replace a PHP configuration file, or compare it with Forge when --check is set.
|
|
3147
|
+
*/
|
|
3148
|
+
async function phpUpdateConfig(ctx) {
|
|
3149
|
+
const server = requireServer(ctx, UPDATE_CONFIG_USAGE);
|
|
3150
|
+
const target = requireConfigTarget(ctx, UPDATE_CONFIG_USAGE);
|
|
3151
|
+
const label = `PHP ${target.type} configuration`;
|
|
3152
|
+
const content = readContentOption(ctx.options, {
|
|
3153
|
+
label,
|
|
3154
|
+
usage: UPDATE_CONFIG_USAGE,
|
|
3155
|
+
formatter: ctx.formatter
|
|
3156
|
+
});
|
|
3157
|
+
await withServer(ctx, server, async (server_id, execCtx) => {
|
|
3158
|
+
if (ctx.options.check) {
|
|
3159
|
+
const remote = await getPhpConfig({
|
|
3160
|
+
server_id,
|
|
3161
|
+
...target
|
|
3162
|
+
}, execCtx);
|
|
3163
|
+
reportTextDrift(label, remote.data, content, ctx.formatter);
|
|
3164
|
+
return;
|
|
3165
|
+
}
|
|
3166
|
+
await updatePhpConfig({
|
|
3167
|
+
server_id,
|
|
3168
|
+
...target,
|
|
3169
|
+
content
|
|
3170
|
+
}, execCtx);
|
|
3171
|
+
ctx.formatter.success(`${label} updated.`);
|
|
3172
|
+
});
|
|
3173
|
+
}
|
|
3174
|
+
/**
|
|
3175
|
+
* Print the PHP max upload size, in megabytes.
|
|
3176
|
+
*/
|
|
3177
|
+
async function phpMaxUploadSize(ctx) {
|
|
3178
|
+
await withServer(ctx, requireServer(ctx, "forge php max-upload-size --server <server_id>"), async (server_id, execCtx) => {
|
|
3179
|
+
const { data } = await getPhpMaxUploadSize({ server_id }, execCtx);
|
|
3180
|
+
if (ctx.formatter.isJson()) ctx.formatter.output({ max_upload_size: data });
|
|
3181
|
+
else console.log(`${data} MB`);
|
|
3182
|
+
});
|
|
3183
|
+
}
|
|
3184
|
+
/**
|
|
3185
|
+
* Set the PHP max upload size, in megabytes.
|
|
3186
|
+
*/
|
|
3187
|
+
async function phpSetMaxUploadSize(args, ctx) {
|
|
3188
|
+
const usage = "forge php set-max-upload-size <megabytes> --server <server_id>";
|
|
3189
|
+
const size = requireWholeNumber(args[0], "megabytes", usage, ctx);
|
|
3190
|
+
await withServer(ctx, requireServer(ctx, usage), async (server_id, execCtx) => {
|
|
3191
|
+
await updatePhpMaxUploadSize({
|
|
3192
|
+
server_id,
|
|
3193
|
+
size
|
|
3194
|
+
}, execCtx);
|
|
3195
|
+
ctx.formatter.success(`PHP max upload size set to ${size} MB.`);
|
|
3196
|
+
});
|
|
3197
|
+
}
|
|
3198
|
+
/**
|
|
3199
|
+
* Print the PHP max execution time, in seconds.
|
|
3200
|
+
*/
|
|
3201
|
+
async function phpMaxExecutionTime(ctx) {
|
|
3202
|
+
await withServer(ctx, requireServer(ctx, "forge php max-execution-time --server <server_id>"), async (server_id, execCtx) => {
|
|
3203
|
+
const { data } = await getPhpMaxExecutionTime({ server_id }, execCtx);
|
|
3204
|
+
if (ctx.formatter.isJson()) ctx.formatter.output({ max_execution_time: data });
|
|
3205
|
+
else console.log(`${data} seconds`);
|
|
3206
|
+
});
|
|
3207
|
+
}
|
|
3208
|
+
/**
|
|
3209
|
+
* Set the PHP max execution time, in seconds.
|
|
3210
|
+
*/
|
|
3211
|
+
async function phpSetMaxExecutionTime(args, ctx) {
|
|
3212
|
+
const usage = "forge php set-max-execution-time <seconds> --server <server_id>";
|
|
3213
|
+
const seconds = requireWholeNumber(args[0], "seconds", usage, ctx);
|
|
3214
|
+
await withServer(ctx, requireServer(ctx, usage), async (server_id, execCtx) => {
|
|
3215
|
+
await updatePhpMaxExecutionTime({
|
|
3216
|
+
server_id,
|
|
3217
|
+
seconds
|
|
3218
|
+
}, execCtx);
|
|
3219
|
+
ctx.formatter.success(`PHP max execution time set to ${seconds} seconds.`);
|
|
3220
|
+
});
|
|
3221
|
+
}
|
|
3222
|
+
/**
|
|
3223
|
+
* Print whether OPcache is enabled.
|
|
3224
|
+
*/
|
|
3225
|
+
async function phpOpcache(ctx) {
|
|
3226
|
+
await withServer(ctx, requireServer(ctx, "forge php opcache --server <server_id>"), async (server_id, execCtx) => {
|
|
3227
|
+
const { data } = await getPhpOpcache({ server_id }, execCtx);
|
|
3228
|
+
if (ctx.formatter.isJson()) ctx.formatter.output({ opcache_enabled: data });
|
|
3229
|
+
else console.log(`OPcache is ${data ? "enabled" : "disabled"}.`);
|
|
3230
|
+
});
|
|
3231
|
+
}
|
|
3232
|
+
/**
|
|
3233
|
+
* Enable OPcache.
|
|
3234
|
+
*/
|
|
3235
|
+
async function phpEnableOpcache(ctx) {
|
|
3236
|
+
await withServer(ctx, requireServer(ctx, "forge php enable-opcache --server <server_id>"), async (server_id, execCtx) => {
|
|
3237
|
+
await enablePhpOpcache({ server_id }, execCtx);
|
|
3238
|
+
ctx.formatter.success("OPcache enabled.");
|
|
3239
|
+
});
|
|
3240
|
+
}
|
|
3241
|
+
/**
|
|
3242
|
+
* Disable OPcache.
|
|
3243
|
+
*/
|
|
3244
|
+
async function phpDisableOpcache(ctx) {
|
|
3245
|
+
await withServer(ctx, requireServer(ctx, "forge php disable-opcache --server <server_id>"), async (server_id, execCtx) => {
|
|
3246
|
+
await disablePhpOpcache({ server_id }, execCtx);
|
|
3247
|
+
ctx.formatter.success("OPcache disabled.");
|
|
3248
|
+
});
|
|
3249
|
+
}
|
|
3250
|
+
//#endregion
|
|
3251
|
+
//#region src/commands/php/command.ts
|
|
3252
|
+
var handlePhpCommand = createCommandRouter({
|
|
3253
|
+
resource: "php",
|
|
3254
|
+
handlers: {
|
|
3255
|
+
list: phpList,
|
|
3256
|
+
ls: phpList,
|
|
3257
|
+
config: phpConfig,
|
|
3258
|
+
"update-config": phpUpdateConfig,
|
|
3259
|
+
"max-upload-size": phpMaxUploadSize,
|
|
3260
|
+
"set-max-upload-size": [phpSetMaxUploadSize, "args"],
|
|
3261
|
+
"max-execution-time": phpMaxExecutionTime,
|
|
3262
|
+
"set-max-execution-time": [phpSetMaxExecutionTime, "args"],
|
|
3263
|
+
opcache: phpOpcache,
|
|
3264
|
+
"enable-opcache": phpEnableOpcache,
|
|
3265
|
+
"disable-opcache": phpDisableOpcache
|
|
3266
|
+
},
|
|
3267
|
+
writeSubcommands: [
|
|
3268
|
+
"update-config",
|
|
3269
|
+
"set-max-upload-size",
|
|
3270
|
+
"set-max-execution-time",
|
|
3271
|
+
"enable-opcache",
|
|
3272
|
+
"disable-opcache"
|
|
3273
|
+
]
|
|
3274
|
+
});
|
|
3275
|
+
//#endregion
|
|
3276
|
+
//#region src/commands/php/help.ts
|
|
3277
|
+
function showPhpHelp(subcommand) {
|
|
3278
|
+
if (subcommand === "list" || subcommand === "ls") console.log(`
|
|
3279
|
+
${colors.bold("forge php list")} - List the PHP versions installed on a server
|
|
3280
|
+
|
|
3281
|
+
${colors.bold("USAGE:")}
|
|
3282
|
+
forge php list --server <server_id>
|
|
3283
|
+
|
|
3284
|
+
${colors.bold("OPTIONS:")}
|
|
3285
|
+
--server <id> Server ID or name (required)
|
|
3286
|
+
-f, --format <fmt> Output format: json, human, table
|
|
3287
|
+
|
|
3288
|
+
${colors.bold("EXAMPLES:")}
|
|
3289
|
+
forge php list --server 123
|
|
3290
|
+
`);
|
|
3291
|
+
else if (subcommand === "config") console.log(`
|
|
3292
|
+
${colors.bold("forge php config")} - Print a PHP configuration file
|
|
3293
|
+
|
|
3294
|
+
${colors.bold("USAGE:")}
|
|
3295
|
+
forge php config --server <server_id> --version <version> --type <cli|fpm|pool>
|
|
3296
|
+
|
|
3297
|
+
${colors.bold("OPTIONS:")}
|
|
3298
|
+
--server <id> Server ID or name (required)
|
|
3299
|
+
--version <ver> PHP version ID, or a version such as 8.3, php83 or php8.3 (required)
|
|
3300
|
+
--type <type> cli (CLI php.ini), fpm (FPM php.ini) or pool (FPM pool) (required)
|
|
3301
|
+
--user <name> FPM pool user (pool only)
|
|
3302
|
+
-f, --format <fmt> Output format: json ({ content }), human
|
|
3303
|
+
|
|
3304
|
+
${colors.bold("EXAMPLES:")}
|
|
3305
|
+
forge php config --server 123 --version 8.3 --type fpm
|
|
3306
|
+
forge php config --server 123 --version 8.3 --type pool > www.conf
|
|
3307
|
+
`);
|
|
3308
|
+
else if (subcommand === "update-config") console.log(`
|
|
3309
|
+
${colors.bold("forge php update-config")} - Replace a PHP configuration file
|
|
3310
|
+
|
|
3311
|
+
${colors.bold("USAGE:")}
|
|
3312
|
+
forge php update-config --server <server_id> --version <version> --type <cli|fpm|pool> --file <path>
|
|
3313
|
+
forge php update-config --server <server_id> --version <version> --type <cli|fpm|pool> --file <path> --check
|
|
3314
|
+
forge php update-config --server <server_id> --version <version> --type <cli|fpm|pool> --content <config>
|
|
3315
|
+
|
|
3316
|
+
${colors.bold("OPTIONS:")}
|
|
3317
|
+
--server <id> Server ID or name (required)
|
|
3318
|
+
--version <ver> PHP version ID, or a version such as 8.3, php83 or php8.3 (required)
|
|
3319
|
+
--type <type> cli (CLI php.ini), fpm (FPM php.ini) or pool (FPM pool) (required)
|
|
3320
|
+
--user <name> FPM pool user (pool only)
|
|
3321
|
+
--file <path> Read the content from a file (use - for stdin)
|
|
3322
|
+
--content <str> New configuration file content
|
|
3323
|
+
--check Compare with the content on Forge and do not write.
|
|
3324
|
+
Exit 0 when in sync, 1 with a line diff (- remote, + local).
|
|
3325
|
+
-f, --format <fmt> Output format: json ({ inSync, diff? } with --check), human
|
|
3326
|
+
|
|
3327
|
+
Give exactly one of --file or --content. Empty content is rejected.
|
|
3328
|
+
The content replaces the whole file.
|
|
3329
|
+
|
|
3330
|
+
${colors.bold("EXAMPLES:")}
|
|
3331
|
+
forge php update-config --server 123 --version 8.3 --type fpm --file php.ini
|
|
3332
|
+
cat php.ini | forge php update-config --server 123 --version 8.3 --type cli --file -
|
|
3333
|
+
forge php update-config --server 123 --version 8.3 --type fpm --file php.ini --check
|
|
3334
|
+
`);
|
|
3335
|
+
else if (subcommand === "max-upload-size" || subcommand === "set-max-upload-size" || subcommand === "max-execution-time" || subcommand === "set-max-execution-time") console.log(`
|
|
3336
|
+
${colors.bold("forge php max-upload-size / max-execution-time")} - Get or set PHP limits
|
|
3337
|
+
|
|
3338
|
+
${colors.bold("USAGE:")}
|
|
3339
|
+
forge php max-upload-size --server <server_id>
|
|
3340
|
+
forge php set-max-upload-size <megabytes> --server <server_id>
|
|
3341
|
+
forge php max-execution-time --server <server_id>
|
|
3342
|
+
forge php set-max-execution-time <seconds> --server <server_id>
|
|
3343
|
+
|
|
3344
|
+
${colors.bold("OPTIONS:")}
|
|
3345
|
+
--server <id> Server ID or name (required)
|
|
3346
|
+
-f, --format <fmt> Output format: json, human
|
|
3347
|
+
|
|
3348
|
+
${colors.bold("EXAMPLES:")}
|
|
3349
|
+
forge php set-max-upload-size 128 --server 123
|
|
3350
|
+
forge php set-max-execution-time 120 --server 123
|
|
3351
|
+
`);
|
|
3352
|
+
else if (subcommand === "opcache" || subcommand === "enable-opcache" || subcommand === "disable-opcache") console.log(`
|
|
3353
|
+
${colors.bold("forge php opcache")} - Get, enable or disable OPcache
|
|
3354
|
+
|
|
3355
|
+
${colors.bold("USAGE:")}
|
|
3356
|
+
forge php opcache --server <server_id>
|
|
3357
|
+
forge php enable-opcache --server <server_id>
|
|
3358
|
+
forge php disable-opcache --server <server_id>
|
|
3359
|
+
|
|
3360
|
+
${colors.bold("OPTIONS:")}
|
|
3361
|
+
--server <id> Server ID or name (required)
|
|
3362
|
+
-f, --format <fmt> Output format: json, human
|
|
3363
|
+
`);
|
|
3364
|
+
else console.log(`
|
|
3365
|
+
${colors.bold("forge php")} - Manage PHP on a server
|
|
3366
|
+
|
|
3367
|
+
${colors.bold("USAGE:")}
|
|
3368
|
+
forge php <subcommand> [options]
|
|
3369
|
+
|
|
3370
|
+
${colors.bold("SUBCOMMANDS:")}
|
|
3371
|
+
list, ls List installed PHP versions
|
|
3372
|
+
config Print a configuration file (--version --type)
|
|
3373
|
+
update-config Replace a configuration file (--version --type --file|--content) [--check]
|
|
3374
|
+
max-upload-size Print the max upload size (MB)
|
|
3375
|
+
set-max-upload-size <mb> Set the max upload size (MB)
|
|
3376
|
+
max-execution-time Print the max execution time (seconds)
|
|
3377
|
+
set-max-execution-time <sec> Set the max execution time (seconds)
|
|
3378
|
+
opcache Print the OPcache status
|
|
3379
|
+
enable-opcache Enable OPcache
|
|
3380
|
+
disable-opcache Disable OPcache
|
|
3381
|
+
|
|
3382
|
+
${colors.bold("OPTIONS:")}
|
|
3383
|
+
--server <id> Server ID or name (required)
|
|
3384
|
+
--version <ver> PHP version ID or version (8.3, php83) (for config commands)
|
|
3385
|
+
--type <type> cli, fpm or pool (for config commands)
|
|
3386
|
+
--user <name> FPM pool user (for --type pool)
|
|
3387
|
+
--file <path> Content file, - for stdin (for update-config)
|
|
3388
|
+
--content <str> New content (for update-config)
|
|
3389
|
+
--check Compare with Forge, do not write (for update-config)
|
|
3390
|
+
|
|
3391
|
+
Run ${colors.cyan("forge php <subcommand> --help")} for details.
|
|
3392
|
+
`);
|
|
3393
|
+
}
|
|
3394
|
+
//#endregion
|
|
2614
3395
|
//#region src/commands/recipes/handlers.ts
|
|
2615
3396
|
async function recipesList(ctx) {
|
|
2616
3397
|
await runCommand(async () => {
|
|
@@ -3449,7 +4230,7 @@ async function servicesRestart(args, ctx) {
|
|
|
3449
4230
|
const [server, service] = args;
|
|
3450
4231
|
if (!server) exitWithValidationError("server", "forge services restart <server> <service>", ctx.formatter);
|
|
3451
4232
|
if (!service) exitWithValidationError("service", "forge services restart <server> <service>", ctx.formatter);
|
|
3452
|
-
const version = ctx.options.version ?
|
|
4233
|
+
const version = typeof ctx.options.version === "string" && ctx.options.version ? ctx.options.version : void 0;
|
|
3453
4234
|
await runCommand(async () => {
|
|
3454
4235
|
if (!RESTARTABLE_SERVICES.includes(service)) throw new ValidationError(`Invalid service "${service}"`, "service", [`Valid services: ${RESTARTABLE_SERVICES.join(", ")}`]);
|
|
3455
4236
|
const token = ctx.getToken();
|
|
@@ -3876,14 +4657,285 @@ async function sshKeysGet(args, ctx) {
|
|
|
3876
4657
|
}, ctx.formatter);
|
|
3877
4658
|
}
|
|
3878
4659
|
//#endregion
|
|
4660
|
+
//#region src/commands/ssh-keys/sync-plan.ts
|
|
4661
|
+
/**
|
|
4662
|
+
* Pure logic for `forge ssh-keys sync`: parse an authorized_keys file, give each key
|
|
4663
|
+
* a stable managed name, and compare the file with the keys on a server.
|
|
4664
|
+
*
|
|
4665
|
+
* The Forge API does not return public keys, so keys are matched by name. The name
|
|
4666
|
+
* ends with a fingerprint of the key, so a changed key gets a new name.
|
|
4667
|
+
*/
|
|
4668
|
+
/** The Forge API documents no name limit: keep names short enough to read in a list. */
|
|
4669
|
+
var MAX_SLUG_LENGTH = 40;
|
|
4670
|
+
var KEY_TYPE = /^(ssh-|ecdsa-sha2-|sk-)[a-z0-9@.-]+$/;
|
|
4671
|
+
var BASE64 = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
4672
|
+
/**
|
|
4673
|
+
* Check the base64 key data of a public key, as OpenSSH does: it must be canonical
|
|
4674
|
+
* base64, and the decoded key must start with its own type as a length-prefixed string.
|
|
4675
|
+
*/
|
|
4676
|
+
function isValidKeyBlob(type, blob) {
|
|
4677
|
+
if (!BASE64.test(blob) || blob.length % 4 !== 0) return false;
|
|
4678
|
+
const data = Buffer.from(blob, "base64");
|
|
4679
|
+
if (data.toString("base64") !== blob || data.length < 4) return false;
|
|
4680
|
+
const typeLength = data.readUInt32BE(0);
|
|
4681
|
+
return 4 + typeLength <= data.length && data.toString("latin1", 4, 4 + typeLength) === type;
|
|
4682
|
+
}
|
|
4683
|
+
/**
|
|
4684
|
+
* Convert a key comment to a name prefix: lowercase, `[a-z0-9-]` only, "key" when empty.
|
|
4685
|
+
*/
|
|
4686
|
+
function slugify(comment) {
|
|
4687
|
+
return comment.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-").slice(0, MAX_SLUG_LENGTH).replaceAll(/^-+|-+$/g, "") || "key";
|
|
4688
|
+
}
|
|
4689
|
+
/**
|
|
4690
|
+
* Managed name of a key: "<slug(comment)>-<first 8 hex chars of the SHA-256 of the key blob>".
|
|
4691
|
+
* The blob is the decoded base64 field, as in an OpenSSH fingerprint.
|
|
4692
|
+
*/
|
|
4693
|
+
function managedKeyName(comment, blob) {
|
|
4694
|
+
const hash = createHash("sha256").update(Buffer.from(blob, "base64")).digest("hex");
|
|
4695
|
+
return `${slugify(comment)}-${hash.slice(0, 8)}`;
|
|
4696
|
+
}
|
|
4697
|
+
/**
|
|
4698
|
+
* Parse an authorized_keys file: one "<type> <base64> [comment]" per line.
|
|
4699
|
+
* Blank lines and "#" comments are ignored.
|
|
4700
|
+
*
|
|
4701
|
+
* @throws ValidationError for a malformed or duplicate key, with its line number.
|
|
4702
|
+
*/
|
|
4703
|
+
function parseAuthorizedKeys(content) {
|
|
4704
|
+
const keys = [];
|
|
4705
|
+
const seen = /* @__PURE__ */ new Map();
|
|
4706
|
+
for (const [index, raw] of content.split(/\r?\n/).entries()) {
|
|
4707
|
+
const line = index + 1;
|
|
4708
|
+
const text = raw.trim();
|
|
4709
|
+
if (!text || text.startsWith("#")) continue;
|
|
4710
|
+
const [type, blob = "", ...rest] = text.split(/\s+/);
|
|
4711
|
+
const problem = !KEY_TYPE.test(type) ? "unknown key type" : !isValidKeyBlob(type, blob) ? "missing or invalid base64 key data" : void 0;
|
|
4712
|
+
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)"]);
|
|
4713
|
+
const first = seen.get(blob);
|
|
4714
|
+
if (first !== void 0) throw new ValidationError(`Line ${line}: duplicate of the key on line ${first}`, "file");
|
|
4715
|
+
seen.set(blob, line);
|
|
4716
|
+
const comment = rest.join(" ");
|
|
4717
|
+
keys.push({
|
|
4718
|
+
name: managedKeyName(comment, blob),
|
|
4719
|
+
key: [
|
|
4720
|
+
type,
|
|
4721
|
+
blob,
|
|
4722
|
+
comment
|
|
4723
|
+
].filter(Boolean).join(" "),
|
|
4724
|
+
line
|
|
4725
|
+
});
|
|
4726
|
+
}
|
|
4727
|
+
return keys;
|
|
4728
|
+
}
|
|
4729
|
+
/**
|
|
4730
|
+
* Compare the file keys with the keys on a server, for one user.
|
|
4731
|
+
* Keys of other users are ignored.
|
|
4732
|
+
*/
|
|
4733
|
+
function planSync(fileKeys, serverKeys, user) {
|
|
4734
|
+
const userKeys = serverKeys.filter((k) => k.user === user);
|
|
4735
|
+
const serverNames = new Set(userKeys.map((k) => k.name));
|
|
4736
|
+
const fileNames = new Set(fileKeys.map((k) => k.name));
|
|
4737
|
+
return {
|
|
4738
|
+
add: fileKeys.filter((k) => !serverNames.has(k.name)),
|
|
4739
|
+
remove: userKeys.filter((k) => !fileNames.has(k.name)),
|
|
4740
|
+
unchanged: fileKeys.filter((k) => serverNames.has(k.name))
|
|
4741
|
+
};
|
|
4742
|
+
}
|
|
4743
|
+
//#endregion
|
|
4744
|
+
//#region src/commands/ssh-keys/sync.ts
|
|
4745
|
+
var USAGE = "forge ssh-keys sync --file <path|-> (--server <id>... | --all-servers) [--user <name>] [--prune] [--dry-run]";
|
|
4746
|
+
function errorMessage(error) {
|
|
4747
|
+
return error instanceof Error ? error.message : String(error);
|
|
4748
|
+
}
|
|
4749
|
+
/**
|
|
4750
|
+
* Read the --server values: repeated and/or comma separated.
|
|
4751
|
+
*/
|
|
4752
|
+
function serverValues(value) {
|
|
4753
|
+
return [value].flat().filter((v) => typeof v === "string").flatMap((v) => v.split(",")).map((v) => v.trim()).filter(Boolean);
|
|
4754
|
+
}
|
|
4755
|
+
/**
|
|
4756
|
+
* Read and parse the --file option. Exits on a missing, unreadable, malformed or empty file.
|
|
4757
|
+
*/
|
|
4758
|
+
function readFileKeys(ctx) {
|
|
4759
|
+
const { file } = ctx.options;
|
|
4760
|
+
const content = readContentFile(file, "file", {
|
|
4761
|
+
label: "SSH keys file",
|
|
4762
|
+
usage: USAGE,
|
|
4763
|
+
formatter: ctx.formatter
|
|
4764
|
+
});
|
|
4765
|
+
let keys;
|
|
4766
|
+
try {
|
|
4767
|
+
keys = parseAuthorizedKeys(content);
|
|
4768
|
+
} catch (error) {
|
|
4769
|
+
exitWithError(error, ctx.formatter);
|
|
4770
|
+
}
|
|
4771
|
+
if (keys.length === 0) exitWithError(new ValidationError(`No SSH keys found in ${String(file)}`, "file"), ctx.formatter);
|
|
4772
|
+
return keys;
|
|
4773
|
+
}
|
|
4774
|
+
/**
|
|
4775
|
+
* Resolve the target servers to ids and names, without duplicates.
|
|
4776
|
+
* Names are matched against one list of servers: one API call for all targets.
|
|
4777
|
+
*/
|
|
4778
|
+
async function resolveTargets(values, allServers, execCtx) {
|
|
4779
|
+
const servers = (await listServers({}, execCtx)).data;
|
|
4780
|
+
if (allServers) return servers.map((s) => ({
|
|
4781
|
+
id: s.id,
|
|
4782
|
+
name: s.name
|
|
4783
|
+
}));
|
|
4784
|
+
const targets = /* @__PURE__ */ new Map();
|
|
4785
|
+
for (const value of values) {
|
|
4786
|
+
const id = Number(matchServerId(value, servers));
|
|
4787
|
+
targets.set(id, servers.find((s) => s.id === id)?.name ?? String(id));
|
|
4788
|
+
}
|
|
4789
|
+
return [...targets].map(([id, name]) => ({
|
|
4790
|
+
id,
|
|
4791
|
+
name
|
|
4792
|
+
}));
|
|
4793
|
+
}
|
|
4794
|
+
function printPlan(syncs, user, prune) {
|
|
4795
|
+
for (const sync of syncs) {
|
|
4796
|
+
console.log(colors.bold(`${sync.name} (${sync.id})`));
|
|
4797
|
+
for (const error of sync.errors) console.log(colors.red(` ✗ ${error}`));
|
|
4798
|
+
if (!sync.plan) continue;
|
|
4799
|
+
const { add, remove, unchanged } = sync.plan;
|
|
4800
|
+
for (const key of add) console.log(colors.green(` + ${key.name}`));
|
|
4801
|
+
if (prune) for (const key of remove) console.log(colors.red(` - ${key.name}`));
|
|
4802
|
+
for (const key of unchanged) console.log(colors.dim(` = ${key.name}`));
|
|
4803
|
+
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)`));
|
|
4804
|
+
}
|
|
4805
|
+
}
|
|
4806
|
+
function printResult(syncs) {
|
|
4807
|
+
for (const sync of syncs) {
|
|
4808
|
+
const summary = `${sync.name} (${sync.id}): ${sync.added.length} added, ${sync.removed.length} removed, ${sync.unchanged.length} unchanged`;
|
|
4809
|
+
if (sync.errors.length === 0) {
|
|
4810
|
+
console.log(colors.green(`✓ ${summary}`));
|
|
4811
|
+
continue;
|
|
4812
|
+
}
|
|
4813
|
+
console.log(colors.red(`✗ ${summary}, ${sync.errors.length} failed`));
|
|
4814
|
+
for (const error of sync.errors) console.log(colors.red(` ${error}`));
|
|
4815
|
+
}
|
|
4816
|
+
}
|
|
4817
|
+
/**
|
|
4818
|
+
* Apply a plan to one server. Each failed operation is recorded and the next one runs.
|
|
4819
|
+
* When an add fails, the removals are skipped: the user must not lose access to the server.
|
|
4820
|
+
*/
|
|
4821
|
+
async function applyPlan(sync, plan, user, prune, execCtx) {
|
|
4822
|
+
const server_id = String(sync.id);
|
|
4823
|
+
let addFailed = false;
|
|
4824
|
+
for (const key of plan.add) try {
|
|
4825
|
+
await createSshKey({
|
|
4826
|
+
server_id,
|
|
4827
|
+
name: key.name,
|
|
4828
|
+
key: key.key,
|
|
4829
|
+
user
|
|
4830
|
+
}, execCtx);
|
|
4831
|
+
sync.added.push(key.name);
|
|
4832
|
+
} catch (error) {
|
|
4833
|
+
addFailed = true;
|
|
4834
|
+
sync.errors.push(`add ${key.name}: ${errorMessage(error)}`);
|
|
4835
|
+
}
|
|
4836
|
+
if (!prune) return;
|
|
4837
|
+
for (const key of plan.remove) {
|
|
4838
|
+
if (addFailed) {
|
|
4839
|
+
sync.errors.push(`remove ${key.name}: skipped because an add failed`);
|
|
4840
|
+
continue;
|
|
4841
|
+
}
|
|
4842
|
+
try {
|
|
4843
|
+
await deleteSshKey({
|
|
4844
|
+
server_id,
|
|
4845
|
+
id: String(key.id)
|
|
4846
|
+
}, execCtx);
|
|
4847
|
+
sync.removed.push(key.name);
|
|
4848
|
+
} catch (error) {
|
|
4849
|
+
sync.errors.push(`remove ${key.name}: ${errorMessage(error)}`);
|
|
4850
|
+
}
|
|
4851
|
+
}
|
|
4852
|
+
}
|
|
4853
|
+
/**
|
|
4854
|
+
* Make the SSH keys of a user on one or more servers match an authorized_keys file.
|
|
4855
|
+
*/
|
|
4856
|
+
async function sshKeysSync(ctx) {
|
|
4857
|
+
const { options, formatter } = ctx;
|
|
4858
|
+
const servers = serverValues(options.server);
|
|
4859
|
+
const allServers = options["all-servers"] === true;
|
|
4860
|
+
const userOption = options.user;
|
|
4861
|
+
const prune = options.prune === true;
|
|
4862
|
+
const dryRun = options["dry-run"] === true;
|
|
4863
|
+
if (options.file === void 0) exitWithValidationError("file", USAGE, formatter);
|
|
4864
|
+
if (userOption !== void 0 && (typeof userOption !== "string" || userOption === "")) exitWithError(new ValidationError("--user requires a name", "user", [`Usage: ${USAGE}`]), formatter);
|
|
4865
|
+
const user = typeof userOption === "string" ? userOption : "forge";
|
|
4866
|
+
if (allServers && options.server !== void 0) exitWithError(new ValidationError("Use either --server or --all-servers, not both", "server", [`Usage: ${USAGE}`]), formatter);
|
|
4867
|
+
if (!allServers && servers.length === 0) exitWithError(new ValidationError("A target is required: --server <id> or --all-servers", "server", [`Usage: ${USAGE}`]), formatter);
|
|
4868
|
+
const fileKeys = readFileKeys(ctx);
|
|
4869
|
+
const failed = await runCommand(async () => {
|
|
4870
|
+
const execCtx = ctx.createExecutorContext(ctx.getToken());
|
|
4871
|
+
const targets = await resolveTargets(servers, allServers, execCtx);
|
|
4872
|
+
const syncs = [];
|
|
4873
|
+
for (const { id, name } of targets) {
|
|
4874
|
+
const sync = {
|
|
4875
|
+
id,
|
|
4876
|
+
name,
|
|
4877
|
+
added: [],
|
|
4878
|
+
removed: [],
|
|
4879
|
+
unchanged: [],
|
|
4880
|
+
errors: []
|
|
4881
|
+
};
|
|
4882
|
+
try {
|
|
4883
|
+
const keys = await listSshKeys({
|
|
4884
|
+
server_id: String(id),
|
|
4885
|
+
user
|
|
4886
|
+
}, execCtx);
|
|
4887
|
+
sync.plan = planSync(fileKeys, keys.data, user);
|
|
4888
|
+
sync.unchanged = sync.plan.unchanged.map((k) => k.name);
|
|
4889
|
+
} catch (error) {
|
|
4890
|
+
sync.errors.push(`list keys: ${errorMessage(error)}`);
|
|
4891
|
+
}
|
|
4892
|
+
syncs.push(sync);
|
|
4893
|
+
}
|
|
4894
|
+
const json = formatter.isJson();
|
|
4895
|
+
if (!json) printPlan(syncs, user, prune);
|
|
4896
|
+
if (dryRun) {
|
|
4897
|
+
for (const sync of syncs) {
|
|
4898
|
+
if (!sync.plan) continue;
|
|
4899
|
+
sync.added = sync.plan.add.map((k) => k.name);
|
|
4900
|
+
if (prune) sync.removed = sync.plan.remove.map((k) => k.name);
|
|
4901
|
+
}
|
|
4902
|
+
if (!json) console.log(colors.cyan("\nDry run: no changes made."));
|
|
4903
|
+
} else {
|
|
4904
|
+
for (const sync of syncs) if (sync.plan) await applyPlan(sync, sync.plan, user, prune, execCtx);
|
|
4905
|
+
if (!json) {
|
|
4906
|
+
console.log("");
|
|
4907
|
+
printResult(syncs);
|
|
4908
|
+
}
|
|
4909
|
+
}
|
|
4910
|
+
if (json) console.log(JSON.stringify({
|
|
4911
|
+
dry_run: dryRun,
|
|
4912
|
+
servers: syncs.map(({ id, name, added, removed, unchanged, errors }) => ({
|
|
4913
|
+
id,
|
|
4914
|
+
name,
|
|
4915
|
+
added,
|
|
4916
|
+
removed,
|
|
4917
|
+
unchanged,
|
|
4918
|
+
errors
|
|
4919
|
+
}))
|
|
4920
|
+
}, null, 2));
|
|
4921
|
+
return syncs.reduce((count, sync) => count + sync.errors.length, 0);
|
|
4922
|
+
}, formatter);
|
|
4923
|
+
if (failed) {
|
|
4924
|
+
if (!formatter.isJson()) formatter.error(`${failed} SSH key operation(s) failed`);
|
|
4925
|
+
process.exit(ExitCode.GENERAL_ERROR);
|
|
4926
|
+
}
|
|
4927
|
+
}
|
|
4928
|
+
//#endregion
|
|
3879
4929
|
//#region src/commands/ssh-keys/command.ts
|
|
3880
4930
|
var handleSshKeysCommand = createCommandRouter({
|
|
3881
4931
|
resource: "ssh-keys",
|
|
3882
4932
|
handlers: {
|
|
3883
4933
|
list: sshKeysList,
|
|
3884
4934
|
ls: sshKeysList,
|
|
3885
|
-
get: [sshKeysGet, "args"]
|
|
3886
|
-
|
|
4935
|
+
get: [sshKeysGet, "args"],
|
|
4936
|
+
sync: sshKeysSync
|
|
4937
|
+
},
|
|
4938
|
+
writeSubcommands: ["sync"]
|
|
3887
4939
|
});
|
|
3888
4940
|
//#endregion
|
|
3889
4941
|
//#region src/commands/ssh-keys/help.ts
|
|
@@ -3899,6 +4951,41 @@ ${colors.bold("forge ssh-keys get")} - Get SSH key details
|
|
|
3899
4951
|
|
|
3900
4952
|
${colors.bold("USAGE:")}
|
|
3901
4953
|
forge ssh-keys get <key_id> --server <server_id>
|
|
4954
|
+
`);
|
|
4955
|
+
else if (subcommand === "sync") console.log(`
|
|
4956
|
+
${colors.bold("forge ssh-keys sync")} - Make the SSH keys of servers match a file
|
|
4957
|
+
|
|
4958
|
+
${colors.bold("USAGE:")}
|
|
4959
|
+
forge ssh-keys sync --file <path|-> --server <id> [--server <id>...] [options]
|
|
4960
|
+
forge ssh-keys sync --file <path|-> --all-servers [options]
|
|
4961
|
+
|
|
4962
|
+
${colors.bold("OPTIONS:")}
|
|
4963
|
+
--file <path> authorized_keys file: one "<type> <base64> [comment]" per line,
|
|
4964
|
+
blank lines and # comments ignored (use - for stdin)
|
|
4965
|
+
--server <id> Target server ID or name (repeatable or comma separated)
|
|
4966
|
+
--all-servers Target every server in the organization
|
|
4967
|
+
--user <name> Server user of the keys (default: forge)
|
|
4968
|
+
--prune Also remove the keys of the user that are not in the file
|
|
4969
|
+
--dry-run Print the plan and change nothing
|
|
4970
|
+
-f, --format <fmt> Output format: json, human
|
|
4971
|
+
|
|
4972
|
+
${colors.bold("HOW IT WORKS:")}
|
|
4973
|
+
Each key gets the name "<comment slug>-<first 8 hex chars of SHA-256 of the key>".
|
|
4974
|
+
The Forge API does not return public keys, so keys are matched by name: a changed
|
|
4975
|
+
key gets a new name. The command lists the plan per server (+ add, - remove,
|
|
4976
|
+
= unchanged), applies it, then prints the result. Without --prune, keys are only
|
|
4977
|
+
added. Servers run one after the other; when an operation fails, the others still
|
|
4978
|
+
run and the command exits non-zero. When an add fails on a server, the removals on
|
|
4979
|
+
that server are skipped, so nobody loses access.
|
|
4980
|
+
|
|
4981
|
+
${colors.bold("JSON OUTPUT:")}
|
|
4982
|
+
{ "dry_run": bool, "servers": [{ id, name, added, removed, unchanged, errors }] }
|
|
4983
|
+
With --dry-run, "added" and "removed" hold the planned changes, not applied ones.
|
|
4984
|
+
|
|
4985
|
+
${colors.bold("EXAMPLES:")}
|
|
4986
|
+
forge ssh-keys sync --file .forge/authorized_keys --server 123 --dry-run
|
|
4987
|
+
forge ssh-keys sync --file .forge/authorized_keys --all-servers --prune
|
|
4988
|
+
cat keys.pub | forge ssh-keys sync --file - --server web-1,web-2 --user deploy
|
|
3902
4989
|
`);
|
|
3903
4990
|
else console.log(`
|
|
3904
4991
|
${colors.bold("forge ssh-keys")} - Manage SSH keys
|
|
@@ -3909,9 +4996,11 @@ ${colors.bold("USAGE:")}
|
|
|
3909
4996
|
${colors.bold("SUBCOMMANDS:")}
|
|
3910
4997
|
list, ls List SSH keys
|
|
3911
4998
|
get <id> Get SSH key details
|
|
4999
|
+
sync Make the SSH keys of servers match a file
|
|
3912
5000
|
|
|
3913
5001
|
${colors.bold("OPTIONS:")}
|
|
3914
|
-
--server <id> Server ID (required)
|
|
5002
|
+
--server <id> Server ID or name (required, except for sync --all-servers)
|
|
5003
|
+
--file <path> authorized_keys file, - for stdin (for sync)
|
|
3915
5004
|
-f, --format <fmt> Output format: json, human, table
|
|
3916
5005
|
|
|
3917
5006
|
Run ${colors.cyan("forge ssh-keys <subcommand> --help")} for details.
|
|
@@ -3961,6 +5050,26 @@ Run ${colors.cyan("forge user <subcommand> --help")} for details.
|
|
|
3961
5050
|
}
|
|
3962
5051
|
//#endregion
|
|
3963
5052
|
//#region src/utils/args.ts
|
|
5053
|
+
/**
|
|
5054
|
+
* Whether an argument can be the value of the option before it.
|
|
5055
|
+
* A lone "-" is a value (it means stdin), other "-" prefixed arguments are options.
|
|
5056
|
+
*/
|
|
5057
|
+
function isOptionValue(arg) {
|
|
5058
|
+
return arg === "-" || !arg.startsWith("-");
|
|
5059
|
+
}
|
|
5060
|
+
/**
|
|
5061
|
+
* Options that accept several values. Every other option keeps the last value.
|
|
5062
|
+
*/
|
|
5063
|
+
var REPEATABLE = /* @__PURE__ */ new Set(["server"]);
|
|
5064
|
+
/**
|
|
5065
|
+
* Set an option value. A repeatable option given more than once collects its string
|
|
5066
|
+
* values into an array (`--server 1 --server 2` → `["1", "2"]`).
|
|
5067
|
+
*/
|
|
5068
|
+
function setOption(options, key, value) {
|
|
5069
|
+
const previous = options[key];
|
|
5070
|
+
if (REPEATABLE.has(key) && typeof value === "string" && previous !== void 0 && typeof previous !== "boolean") options[key] = [previous, value].flat();
|
|
5071
|
+
else options[key] = value;
|
|
5072
|
+
}
|
|
3964
5073
|
function parseArgs(argv = process.argv.slice(2)) {
|
|
3965
5074
|
const options = {};
|
|
3966
5075
|
const positional = [];
|
|
@@ -3969,24 +5078,22 @@ function parseArgs(argv = process.argv.slice(2)) {
|
|
|
3969
5078
|
const arg = argv[i];
|
|
3970
5079
|
if (arg.startsWith("--")) {
|
|
3971
5080
|
const equalIndex = arg.indexOf("=");
|
|
3972
|
-
if (equalIndex > -1)
|
|
3973
|
-
|
|
3974
|
-
options[key] = arg.slice(equalIndex + 1);
|
|
3975
|
-
} else {
|
|
5081
|
+
if (equalIndex > -1) setOption(options, arg.slice(2, equalIndex), arg.slice(equalIndex + 1));
|
|
5082
|
+
else {
|
|
3976
5083
|
const key = arg.slice(2);
|
|
3977
5084
|
const nextArg = argv[i + 1];
|
|
3978
|
-
if (nextArg !== void 0 &&
|
|
3979
|
-
options
|
|
5085
|
+
if (nextArg !== void 0 && isOptionValue(nextArg)) {
|
|
5086
|
+
setOption(options, key, nextArg);
|
|
3980
5087
|
i++;
|
|
3981
|
-
} else options
|
|
5088
|
+
} else setOption(options, key, true);
|
|
3982
5089
|
}
|
|
3983
5090
|
} else if (arg.startsWith("-") && arg.length > 1) if (arg.length === 2) {
|
|
3984
5091
|
const key = arg[1];
|
|
3985
5092
|
const nextArg = argv[i + 1];
|
|
3986
|
-
if (nextArg !== void 0 &&
|
|
3987
|
-
options
|
|
5093
|
+
if (nextArg !== void 0 && isOptionValue(nextArg)) {
|
|
5094
|
+
setOption(options, key, nextArg);
|
|
3988
5095
|
i++;
|
|
3989
|
-
} else options
|
|
5096
|
+
} else setOption(options, key, true);
|
|
3990
5097
|
} else for (let j = 1; j < arg.length; j++) options[arg[j]] = true;
|
|
3991
5098
|
else if (command.length < 2) command.push(arg);
|
|
3992
5099
|
else positional.push(arg);
|
|
@@ -3997,9 +5104,17 @@ function parseArgs(argv = process.argv.slice(2)) {
|
|
|
3997
5104
|
positional
|
|
3998
5105
|
};
|
|
3999
5106
|
}
|
|
5107
|
+
/**
|
|
5108
|
+
* Whether the arguments ask for the CLI version: a bare `--version` or `-v`
|
|
5109
|
+
* with no command. Commands such as `php config` and `services restart` take
|
|
5110
|
+
* `--version <value>`, so a bare `--version` after a command is not a version request.
|
|
5111
|
+
*/
|
|
5112
|
+
function isVersionRequest({ command, options }) {
|
|
5113
|
+
return command.length === 0 && (options.version === true || options.v === true);
|
|
5114
|
+
}
|
|
4000
5115
|
//#endregion
|
|
4001
5116
|
//#region src/cli.ts
|
|
4002
|
-
var VERSION = "0.4.
|
|
5117
|
+
var VERSION = "0.4.8";
|
|
4003
5118
|
function showHelp() {
|
|
4004
5119
|
console.log(`
|
|
4005
5120
|
${colors.bold("forge")} v${VERSION}
|
|
@@ -4025,6 +5140,9 @@ ${colors.bold("COMMANDS:")}
|
|
|
4025
5140
|
deployments, d Manage deployments
|
|
4026
5141
|
list, ls List deployments (requires --server --site)
|
|
4027
5142
|
deploy Trigger a deployment (requires --server --site)
|
|
5143
|
+
logs [id] Show deployment output (requires --server --site)
|
|
5144
|
+
script Show the deployment script (requires --server --site)
|
|
5145
|
+
update-script Update the deployment script (requires --server --site --file|--content) [--check]
|
|
4028
5146
|
|
|
4029
5147
|
databases, db Manage databases
|
|
4030
5148
|
list, ls List databases (requires --server)
|
|
@@ -4043,11 +5161,11 @@ ${colors.bold("COMMANDS:")}
|
|
|
4043
5161
|
|
|
4044
5162
|
env Manage environment variables
|
|
4045
5163
|
get Get env variables (requires --server --site)
|
|
4046
|
-
update Update env variables (requires --server --site --content)
|
|
5164
|
+
update Update env variables (requires --server --site --file|--content) [--check]
|
|
4047
5165
|
|
|
4048
5166
|
nginx Manage nginx configuration
|
|
4049
5167
|
get Get nginx config (requires --server --site)
|
|
4050
|
-
update Update nginx config (requires --server --site --content)
|
|
5168
|
+
update Update nginx config (requires --server --site --file|--content) [--check]
|
|
4051
5169
|
|
|
4052
5170
|
certificates, certs Manage SSL certificates (per-domain in v2)
|
|
4053
5171
|
get Get certificate for a domain (requires --server --site --domain)
|
|
@@ -4064,6 +5182,7 @@ ${colors.bold("COMMANDS:")}
|
|
|
4064
5182
|
ssh-keys Manage SSH keys
|
|
4065
5183
|
list, ls List SSH keys (requires --server)
|
|
4066
5184
|
get <id> Get SSH key details (requires --server)
|
|
5185
|
+
sync Make server SSH keys match a file (requires --file, --server|--all-servers)
|
|
4067
5186
|
|
|
4068
5187
|
backups Manage backup configurations
|
|
4069
5188
|
list, ls List backup configurations (requires --server)
|
|
@@ -4086,6 +5205,20 @@ ${colors.bold("COMMANDS:")}
|
|
|
4086
5205
|
list, ls List services on a server (forge services list <server>)
|
|
4087
5206
|
restart <service> Restart a service (forge services restart <server> <service>)
|
|
4088
5207
|
|
|
5208
|
+
php Manage PHP versions, configuration, limits and OPcache
|
|
5209
|
+
list, ls List installed PHP versions (requires --server)
|
|
5210
|
+
config Show a configuration file (requires --server --version --type)
|
|
5211
|
+
update-config Replace a configuration file (requires --server --version --type --file|--content) [--check]
|
|
5212
|
+
max-upload-size Show the max upload size (requires --server)
|
|
5213
|
+
set-max-upload-size <mb>
|
|
5214
|
+
Set the max upload size (requires --server)
|
|
5215
|
+
max-execution-time Show the max execution time (requires --server)
|
|
5216
|
+
set-max-execution-time <seconds>
|
|
5217
|
+
Set the max execution time (requires --server)
|
|
5218
|
+
opcache Show the OPcache status (requires --server)
|
|
5219
|
+
enable-opcache Enable OPcache (requires --server)
|
|
5220
|
+
disable-opcache Disable OPcache (requires --server)
|
|
5221
|
+
|
|
4089
5222
|
user Display authenticated user profile
|
|
4090
5223
|
get Get the authenticated user profile
|
|
4091
5224
|
|
|
@@ -4167,14 +5300,15 @@ ${colors.bold("ENVIRONMENT VARIABLES:")}
|
|
|
4167
5300
|
`);
|
|
4168
5301
|
}
|
|
4169
5302
|
async function main() {
|
|
4170
|
-
const
|
|
5303
|
+
const args = parseArgs();
|
|
5304
|
+
const { command, options, positional } = args;
|
|
4171
5305
|
const [mainCommand, subcommand] = command;
|
|
4172
5306
|
const wantsHelp = options.help !== void 0 || options.h !== void 0;
|
|
4173
5307
|
if (wantsHelp && !mainCommand) {
|
|
4174
5308
|
showHelp();
|
|
4175
5309
|
process.exit(0);
|
|
4176
5310
|
}
|
|
4177
|
-
if (
|
|
5311
|
+
if (isVersionRequest(args)) {
|
|
4178
5312
|
console.log(VERSION);
|
|
4179
5313
|
process.exit(0);
|
|
4180
5314
|
}
|
|
@@ -4309,6 +5443,13 @@ async function main() {
|
|
|
4309
5443
|
}
|
|
4310
5444
|
await handleServicesCommand(subcommand ?? "list", positional, options);
|
|
4311
5445
|
break;
|
|
5446
|
+
case "php":
|
|
5447
|
+
if (wantsHelp) {
|
|
5448
|
+
showPhpHelp(subcommand);
|
|
5449
|
+
process.exit(0);
|
|
5450
|
+
}
|
|
5451
|
+
await handlePhpCommand(subcommand ?? "list", positional, options);
|
|
5452
|
+
break;
|
|
4312
5453
|
case "user":
|
|
4313
5454
|
if (wantsHelp) {
|
|
4314
5455
|
showUserHelp(subcommand);
|