@studiometa/forge-cli 0.4.5 → 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.
Files changed (57) hide show
  1. package/README.md +68 -6
  2. package/dist/cli.js +1406 -51
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/completion.d.ts.map +1 -1
  5. package/dist/commands/daemons/command.d.ts.map +1 -1
  6. package/dist/commands/daemons/handlers.d.ts +1 -0
  7. package/dist/commands/daemons/handlers.d.ts.map +1 -1
  8. package/dist/commands/daemons/help.d.ts.map +1 -1
  9. package/dist/commands/deployments/command.d.ts.map +1 -1
  10. package/dist/commands/deployments/handlers.d.ts +3 -0
  11. package/dist/commands/deployments/handlers.d.ts.map +1 -1
  12. package/dist/commands/deployments/help.d.ts.map +1 -1
  13. package/dist/commands/env/env-diff.d.ts +29 -0
  14. package/dist/commands/env/env-diff.d.ts.map +1 -0
  15. package/dist/commands/env/handlers.d.ts.map +1 -1
  16. package/dist/commands/env/help.d.ts.map +1 -1
  17. package/dist/commands/nginx/handlers.d.ts.map +1 -1
  18. package/dist/commands/nginx/help.d.ts.map +1 -1
  19. package/dist/commands/php/command.d.ts +2 -0
  20. package/dist/commands/php/command.d.ts.map +1 -0
  21. package/dist/commands/php/handlers.d.ts +42 -0
  22. package/dist/commands/php/handlers.d.ts.map +1 -0
  23. package/dist/commands/php/help.d.ts +2 -0
  24. package/dist/commands/php/help.d.ts.map +1 -0
  25. package/dist/commands/php/index.d.ts +3 -0
  26. package/dist/commands/php/index.d.ts.map +1 -0
  27. package/dist/commands/services/command.d.ts +2 -0
  28. package/dist/commands/services/command.d.ts.map +1 -0
  29. package/dist/commands/services/handlers.d.ts +10 -0
  30. package/dist/commands/services/handlers.d.ts.map +1 -0
  31. package/dist/commands/services/help.d.ts +2 -0
  32. package/dist/commands/services/help.d.ts.map +1 -0
  33. package/dist/commands/services/index.d.ts +3 -0
  34. package/dist/commands/services/index.d.ts.map +1 -0
  35. package/dist/commands/ssh-keys/command.d.ts.map +1 -1
  36. package/dist/commands/ssh-keys/help.d.ts.map +1 -1
  37. package/dist/commands/ssh-keys/sync-plan.d.ts +56 -0
  38. package/dist/commands/ssh-keys/sync-plan.d.ts.map +1 -0
  39. package/dist/commands/ssh-keys/sync.d.ts +6 -0
  40. package/dist/commands/ssh-keys/sync.d.ts.map +1 -0
  41. package/dist/{error-handler-peS_8nl4.js → error-handler-Adi84RcF.js} +21 -5
  42. package/dist/error-handler-Adi84RcF.js.map +1 -0
  43. package/dist/error-handler.d.ts +5 -0
  44. package/dist/error-handler.d.ts.map +1 -1
  45. package/dist/index.js +1 -1
  46. package/dist/output.d.ts +5 -0
  47. package/dist/output.d.ts.map +1 -1
  48. package/dist/utils/args.d.ts +6 -0
  49. package/dist/utils/args.d.ts.map +1 -1
  50. package/dist/utils/content-source.d.ts +24 -0
  51. package/dist/utils/content-source.d.ts.map +1 -0
  52. package/dist/utils/drift.d.ts +33 -0
  53. package/dist/utils/drift.d.ts.map +1 -0
  54. package/dist/utils/resolve.d.ts +8 -0
  55. package/dist/utils/resolve.d.ts.map +1 -1
  56. package/package.json +3 -3
  57. package/dist/error-handler-peS_8nl4.js.map +0 -1
package/dist/cli.js CHANGED
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import { S as isOutputFormat, _ as ApiError, a as createContext, b as ValidationError, c as colors, d as deleteToken, g as setToken, h as setOrganizationSlug, i as runCommand, l as setColorEnabled, m as getToken, n as exitWithValidationError, p as getOrganizationSlug, s as OutputFormatter, u as createConfigStore } from "./error-handler-peS_8nl4.js";
3
- import { activateCertificate, createAuditLogger, createBackupConfig, createCommand, createDatabaseUser, createMonitor, createNginxTemplate, createRedirectRule, createScheduledJob, createSecurityRule, deleteBackupConfig, deleteDatabaseUser, deleteMonitor, deleteNginxTemplate, deleteRedirectRule, deleteScheduledJob, deleteSecurityRule, deploySiteAndWait, getBackupConfig, getCertificate, getCommand, getDaemon, getDatabase, getDatabaseUser, 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, listSites, listSshKeys, matchByName, rebootServer, restartDaemon, 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
- const servers = (await listServers({}, execCtx)).data;
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,24 +345,26 @@ _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 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"
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
- local daemons_cmds="list ls get restart"
357
+ local daemons_cmds="list ls get restart logs log"
349
358
  local env_cmds="get update"
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"
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"
357
368
  local user_cmds="get"
358
369
  local monitors_cmds="list ls get create delete"
359
370
  local nginx_templates_cmds="list ls get create update delete"
@@ -363,7 +374,7 @@ _forge_completions() {
363
374
  local completion_cmds="bash zsh fish"
364
375
 
365
376
  # Global options
366
- 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"
367
378
 
368
379
  # Format options
369
380
  local formats="json human table"
@@ -422,6 +433,12 @@ _forge_completions() {
422
433
  scheduled-jobs)
423
434
  COMPREPLY=( $(compgen -W "\${scheduled_jobs_cmds}" -- "\${cur}") )
424
435
  ;;
436
+ services)
437
+ COMPREPLY=( $(compgen -W "\${services_cmds}" -- "\${cur}") )
438
+ ;;
439
+ php)
440
+ COMPREPLY=( $(compgen -W "\${php_cmds}" -- "\${cur}") )
441
+ ;;
425
442
  user)
426
443
  COMPREPLY=( $(compgen -W "\${user_cmds}" -- "\${cur}") )
427
444
  ;;
@@ -454,7 +471,11 @@ _forge_completions() {
454
471
  --format|-f)
455
472
  COMPREPLY=( $(compgen -W "\${formats}" -- "\${cur}") )
456
473
  ;;
457
- --token|--server|--site|--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)
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)
458
479
  # No completion for values requiring arguments
459
480
  COMPREPLY=()
460
481
  ;;
@@ -505,6 +526,8 @@ _forge() {
505
526
  'backups:Manage backup configurations'
506
527
  'commands:Manage site commands'
507
528
  'scheduled-jobs:Manage scheduled jobs'
529
+ 'services:List and restart server services'
530
+ 'php:Manage PHP versions, configuration, limits and OPcache'
508
531
  'user:Display authenticated user profile'
509
532
  'monitors:Manage server monitors'
510
533
  'nginx-templates:Manage Nginx templates'
@@ -552,6 +575,10 @@ _forge() {
552
575
  'list:List deployments'
553
576
  'ls:List deployments (alias)'
554
577
  'deploy:Trigger a deployment'
578
+ 'logs:Show deployment output'
579
+ 'log:Show deployment output (alias)'
580
+ 'script:Show the deployment script'
581
+ 'update-script:Update the deployment script'
555
582
  )
556
583
  _describe 'deployments command' deployments_cmds
557
584
  ;;
@@ -582,6 +609,8 @@ _forge() {
582
609
  'ls:List daemons (alias)'
583
610
  'get:Get daemon details'
584
611
  'restart:Restart a daemon'
612
+ 'logs:Show daemon log output'
613
+ 'log:Show daemon log output (alias)'
585
614
  )
586
615
  _describe 'daemons command' daemons_cmds
587
616
  ;;
@@ -624,6 +653,7 @@ _forge() {
624
653
  'list:List SSH keys'
625
654
  'ls:List SSH keys (alias)'
626
655
  'get:Get SSH key details'
656
+ 'sync:Make server SSH keys match a file'
627
657
  )
628
658
  _describe 'ssh-keys command' ssh_keys_cmds
629
659
  ;;
@@ -659,6 +689,32 @@ _forge() {
659
689
  )
660
690
  _describe 'scheduled-jobs command' scheduled_jobs_cmds
661
691
  ;;
692
+ services)
693
+ local -a services_cmds
694
+ services_cmds=(
695
+ 'list:List services on a server'
696
+ 'ls:List services on a server (alias)'
697
+ 'restart:Restart a service'
698
+ )
699
+ _describe 'services command' services_cmds
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
+ ;;
662
718
  user)
663
719
  local -a user_cmds
664
720
  user_cmds=(
@@ -740,7 +796,8 @@ _forge() {
740
796
  '(-f --format)'{-f,--format}'[Output format]:format:(json human table)' \\
741
797
  '--no-color[Disable colored output]' \\
742
798
  '(-h --help)'{-h,--help}'[Show help]' \\
743
- '(-v --version)'{-v,--version}'[Show version]' \\
799
+ '-v[Show version]' \\
800
+ '--version[PHP version (php, services)]:version:' \\
744
801
  '--name[Name]:name:' \\
745
802
  '--command[Command]:command:' \\
746
803
  '--content[Content]:content:' \\
@@ -764,7 +821,13 @@ _forge() {
764
821
  '--region[Region]:region:' \\
765
822
  '--size[Size]:size:' \\
766
823
  '--project-type[Project type]:project type:' \\
767
- '--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]'
768
831
  ;;
769
832
  esac
770
833
  }
@@ -795,6 +858,8 @@ complete -c forge -f -n "__fish_use_subcommand" -a "ssh-keys" -d "Manage SSH key
795
858
  complete -c forge -f -n "__fish_use_subcommand" -a "backups" -d "Manage backup configurations"
796
859
  complete -c forge -f -n "__fish_use_subcommand" -a "commands" -d "Manage site commands"
797
860
  complete -c forge -f -n "__fish_use_subcommand" -a "scheduled-jobs" -d "Manage scheduled jobs"
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"
798
863
  complete -c forge -f -n "__fish_use_subcommand" -a "user" -d "Display authenticated user profile"
799
864
  complete -c forge -f -n "__fish_use_subcommand" -a "monitors" -d "Manage server monitors"
800
865
  complete -c forge -f -n "__fish_use_subcommand" -a "nginx-templates" -d "Manage Nginx templates"
@@ -824,6 +889,10 @@ complete -c forge -f -n "__fish_seen_subcommand_from sites" -a "get" -d "Get sit
824
889
  complete -c forge -f -n "__fish_seen_subcommand_from deployments d" -a "list" -d "List deployments"
825
890
  complete -c forge -f -n "__fish_seen_subcommand_from deployments d" -a "ls" -d "List deployments (alias)"
826
891
  complete -c forge -f -n "__fish_seen_subcommand_from deployments d" -a "deploy" -d "Trigger a deployment"
892
+ complete -c forge -f -n "__fish_seen_subcommand_from deployments d" -a "logs" -d "Show deployment output"
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"
827
896
 
828
897
  # databases subcommands
829
898
  complete -c forge -f -n "__fish_seen_subcommand_from databases db" -a "list" -d "List databases"
@@ -842,6 +911,8 @@ complete -c forge -f -n "__fish_seen_subcommand_from daemons" -a "list" -d "List
842
911
  complete -c forge -f -n "__fish_seen_subcommand_from daemons" -a "ls" -d "List daemons (alias)"
843
912
  complete -c forge -f -n "__fish_seen_subcommand_from daemons" -a "get" -d "Get daemon details"
844
913
  complete -c forge -f -n "__fish_seen_subcommand_from daemons" -a "restart" -d "Restart a daemon"
914
+ complete -c forge -f -n "__fish_seen_subcommand_from daemons" -a "logs" -d "Show daemon log output"
915
+ complete -c forge -f -n "__fish_seen_subcommand_from daemons" -a "log" -d "Show daemon log output (alias)"
845
916
 
846
917
  # env subcommands
847
918
  complete -c forge -f -n "__fish_seen_subcommand_from env" -a "get" -d "Get env variables"
@@ -865,6 +936,7 @@ complete -c forge -f -n "__fish_seen_subcommand_from firewall-rules fw" -a "get"
865
936
  complete -c forge -f -n "__fish_seen_subcommand_from ssh-keys" -a "list" -d "List SSH keys"
866
937
  complete -c forge -f -n "__fish_seen_subcommand_from ssh-keys" -a "ls" -d "List SSH keys (alias)"
867
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"
868
940
 
869
941
  # backups subcommands
870
942
  complete -c forge -f -n "__fish_seen_subcommand_from backups" -a "list" -d "List backup configurations"
@@ -886,6 +958,24 @@ complete -c forge -f -n "__fish_seen_subcommand_from scheduled-jobs" -a "get" -d
886
958
  complete -c forge -f -n "__fish_seen_subcommand_from scheduled-jobs" -a "create" -d "Create a scheduled job"
887
959
  complete -c forge -f -n "__fish_seen_subcommand_from scheduled-jobs" -a "delete" -d "Delete a scheduled job"
888
960
 
961
+ # services subcommands
962
+ complete -c forge -f -n "__fish_seen_subcommand_from services" -a "list" -d "List services on a server"
963
+ complete -c forge -f -n "__fish_seen_subcommand_from services" -a "ls" -d "List services on a server (alias)"
964
+ complete -c forge -f -n "__fish_seen_subcommand_from services" -a "restart" -d "Restart a service"
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
+
889
979
  # user subcommands
890
980
  complete -c forge -f -n "__fish_seen_subcommand_from user" -a "get" -d "Get the authenticated user profile"
891
981
 
@@ -961,6 +1051,12 @@ complete -c forge -l region -d "Region" -r
961
1051
  complete -c forge -l size -d "Size" -r
962
1052
  complete -c forge -l project-type -d "Project type" -r
963
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"
964
1060
  `;
965
1061
  /**
966
1062
  * Get the appropriate completion file path for the shell
@@ -1432,6 +1528,22 @@ async function daemonsGet(args, ctx) {
1432
1528
  ]);
1433
1529
  }, ctx.formatter);
1434
1530
  }
1531
+ async function daemonsLogs(args, ctx) {
1532
+ const [id] = args;
1533
+ const server = String(ctx.options.server ?? "");
1534
+ if (!id) exitWithValidationError("daemon_id", "forge daemons logs <daemon_id> --server <server_id>", ctx.formatter);
1535
+ if (!server) exitWithValidationError("server_id", "forge daemons logs <daemon_id> --server <server_id>", ctx.formatter);
1536
+ await runCommand(async () => {
1537
+ const token = ctx.getToken();
1538
+ const execCtx = ctx.createExecutorContext(token);
1539
+ const result = await getDaemonLog({
1540
+ server_id: await resolveServerId(server, execCtx),
1541
+ id
1542
+ }, execCtx);
1543
+ if (result.data) ctx.formatter.outputText(result.data);
1544
+ else ctx.formatter.info("No log output available.");
1545
+ }, ctx.formatter);
1546
+ }
1435
1547
  async function daemonsUpdate(args, ctx) {
1436
1548
  const [id] = args;
1437
1549
  const server = String(ctx.options.server ?? "");
@@ -1483,6 +1595,8 @@ var handleDaemonsCommand = createCommandRouter({
1483
1595
  list: daemonsList,
1484
1596
  ls: daemonsList,
1485
1597
  get: [daemonsGet, "args"],
1598
+ logs: [daemonsLogs, "args"],
1599
+ log: [daemonsLogs, "args"],
1486
1600
  restart: [daemonsRestart, "args"],
1487
1601
  update: [daemonsUpdate, "args"]
1488
1602
  },
@@ -1502,6 +1616,12 @@ ${colors.bold("forge daemons get")} - Get daemon details
1502
1616
 
1503
1617
  ${colors.bold("USAGE:")}
1504
1618
  forge daemons get <daemon_id> --server <server_id>
1619
+ `);
1620
+ else if (subcommand === "logs" || subcommand === "log") console.log(`
1621
+ ${colors.bold("forge daemons logs")} - Show the log output of a daemon
1622
+
1623
+ ${colors.bold("USAGE:")}
1624
+ forge daemons logs <daemon_id> --server <server_id>
1505
1625
  `);
1506
1626
  else if (subcommand === "update") console.log(`
1507
1627
  ${colors.bold("forge daemons update")} - Update a daemon
@@ -1524,6 +1644,7 @@ ${colors.bold("USAGE:")}
1524
1644
  ${colors.bold("SUBCOMMANDS:")}
1525
1645
  list, ls List daemons
1526
1646
  get <id> Get daemon details
1647
+ logs <id> Show the log output of a daemon
1527
1648
  update <id> Update a daemon
1528
1649
  restart <id> Restart a daemon
1529
1650
 
@@ -1810,6 +1931,133 @@ Run ${colors.cyan("forge database-users <subcommand> --help")} for details.
1810
1931
  `);
1811
1932
  }
1812
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
1813
2061
  //#region src/commands/deployments/handlers.ts
1814
2062
  async function deploymentsList(ctx) {
1815
2063
  const server = String(ctx.options.server ?? "");
@@ -1832,17 +2080,113 @@ async function deploymentsList(ctx) {
1832
2080
  ], "No deployments found.", (d) => `${String(d.id).padEnd(8)} ${d.status.padEnd(12)} ${(d.commit.hash ?? "—").padEnd(10)} ${d.started_at}`);
1833
2081
  }, ctx.formatter);
1834
2082
  }
2083
+ async function deploymentsLogs(args, ctx) {
2084
+ const [deploymentId] = args;
2085
+ const server = String(ctx.options.server ?? "");
2086
+ const site = String(ctx.options.site ?? "");
2087
+ if (!server) exitWithValidationError("server_id", "forge deployments logs [deployment_id] --server <server_id> --site <site_id>", ctx.formatter);
2088
+ if (!site) exitWithValidationError("site_id", "forge deployments logs [deployment_id] --server <server_id> --site <site_id>", ctx.formatter);
2089
+ await runCommand(async () => {
2090
+ const token = ctx.getToken();
2091
+ const execCtx = ctx.createExecutorContext(token);
2092
+ const server_id = await resolveServerId(server, execCtx);
2093
+ const site_id = await resolveSiteId(site, server_id, execCtx);
2094
+ let deployment_id = deploymentId;
2095
+ if (!deployment_id) {
2096
+ const deployments = await listDeployments({
2097
+ server_id,
2098
+ site_id
2099
+ }, execCtx);
2100
+ if (deployments.data.length === 0) {
2101
+ ctx.formatter.info("No deployments found.");
2102
+ return;
2103
+ }
2104
+ deployment_id = String(deployments.data[0].id);
2105
+ }
2106
+ const result = await getDeploymentLog({
2107
+ server_id,
2108
+ site_id,
2109
+ deployment_id
2110
+ }, execCtx);
2111
+ if (result.data) ctx.formatter.outputText(result.data);
2112
+ else ctx.formatter.info("No log output available.");
2113
+ }, ctx.formatter);
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
+ }
1835
2164
  async function deploymentsDeploy(ctx) {
2165
+ const usage = "forge deployments deploy --server <server_id> --site <site_id> [--script-file <path>]";
1836
2166
  const server = String(ctx.options.server ?? "");
1837
2167
  const site = String(ctx.options.site ?? "");
1838
2168
  const streamLogs = Boolean(ctx.options.stream);
1839
- if (!server) exitWithValidationError("server_id", "forge deployments deploy --server <server_id> --site <site_id>", ctx.formatter);
1840
- if (!site) exitWithValidationError("site_id", "forge deployments deploy --server <server_id> --site <site_id>", ctx.formatter);
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
+ });
1841
2177
  await runCommand(async () => {
1842
2178
  const token = ctx.getToken();
1843
2179
  const execCtx = ctx.createExecutorContext(token);
1844
2180
  const server_id = await resolveServerId(server, execCtx);
1845
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
+ }
1846
2190
  const result = await deploySiteAndWait({
1847
2191
  server_id,
1848
2192
  site_id,
@@ -1868,9 +2212,13 @@ var handleDeploymentsCommand = createCommandRouter({
1868
2212
  handlers: {
1869
2213
  list: deploymentsList,
1870
2214
  ls: deploymentsList,
1871
- deploy: deploymentsDeploy
2215
+ deploy: deploymentsDeploy,
2216
+ logs: [deploymentsLogs, "args"],
2217
+ log: [deploymentsLogs, "args"],
2218
+ script: deploymentsScript,
2219
+ "update-script": deploymentsUpdateScript
1872
2220
  },
1873
- writeSubcommands: ["deploy"]
2221
+ writeSubcommands: ["deploy", "update-script"]
1874
2222
  });
1875
2223
  //#endregion
1876
2224
  //#region src/commands/deployments/help.ts
@@ -1886,17 +2234,68 @@ ${colors.bold("OPTIONS:")}
1886
2234
  --site <id> Site ID (required)
1887
2235
  -f, --format <fmt> Output format: json, human, table
1888
2236
  `);
1889
- else if (subcommand === "deploy") console.log(`
1890
- ${colors.bold("forge deployments deploy")} - Trigger a deployment
2237
+ else if (subcommand === "logs" || subcommand === "log") console.log(`
2238
+ ${colors.bold("forge deployments logs")} - Show output of a deployment (latest if no id)
1891
2239
 
1892
2240
  ${colors.bold("USAGE:")}
1893
- forge deployments deploy --server <server_id> --site <site_id> [options]
2241
+ forge deployments logs [deployment_id] --server <server_id> --site <site_id>
1894
2242
 
1895
2243
  ${colors.bold("OPTIONS:")}
1896
2244
  --server <id> Server ID (required)
1897
2245
  --site <id> Site ID (required)
1898
- --stream Stream deployment logs in real-time (default: show progress)
1899
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
2285
+ `);
2286
+ else if (subcommand === "deploy") console.log(`
2287
+ ${colors.bold("forge deployments deploy")} - Trigger a deployment
2288
+
2289
+ ${colors.bold("USAGE:")}
2290
+ forge deployments deploy --server <server_id> --site <site_id> [options]
2291
+
2292
+ ${colors.bold("OPTIONS:")}
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
1900
2299
  `);
1901
2300
  else console.log(`
1902
2301
  ${colors.bold("forge deployments")} - Manage deployments
@@ -1910,16 +2309,96 @@ ${colors.bold("ALIASES:")}
1910
2309
  ${colors.bold("SUBCOMMANDS:")}
1911
2310
  list, ls List deployments for a site
1912
2311
  deploy Trigger a deployment
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
1913
2315
 
1914
2316
  ${colors.bold("OPTIONS:")}
1915
- --server <id> Server ID (required)
1916
- --site <id> Site ID (required)
1917
- -f, --format <fmt> Output format: json, human, table
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
1918
2324
 
1919
2325
  Run ${colors.cyan("forge deployments <subcommand> --help")} for details.
1920
2326
  `);
1921
2327
  }
1922
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
1923
2402
  //#region src/commands/env/handlers.ts
1924
2403
  async function envGet(ctx) {
1925
2404
  const server = String(ctx.options.server ?? "");
@@ -1939,19 +2418,32 @@ async function envGet(ctx) {
1939
2418
  }, ctx.formatter);
1940
2419
  }
1941
2420
  async function envUpdate(ctx) {
2421
+ const usage = "forge env update --server <server_id> --site <site_id> (--file <path|-> | --content <content>) [--check]";
1942
2422
  const server = String(ctx.options.server ?? "");
1943
2423
  const site = String(ctx.options.site ?? "");
1944
- const content = String(ctx.options.content ?? "");
1945
- if (!server) exitWithValidationError("server_id", "forge env update --server <server_id> --site <site_id> --content <content>", ctx.formatter);
1946
- if (!site) exitWithValidationError("site_id", "forge env update --server <server_id> --site <site_id> --content <content>", ctx.formatter);
1947
- if (!content) exitWithValidationError("content", "forge env update --server <server_id> --site <site_id> --content <content>", ctx.formatter);
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
+ });
1948
2432
  await runCommand(async () => {
1949
2433
  const token = ctx.getToken();
1950
2434
  const execCtx = ctx.createExecutorContext(token);
1951
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
+ }
1952
2444
  await updateEnv({
1953
2445
  server_id,
1954
- site_id: await resolveSiteId(site, server_id, execCtx),
2446
+ site_id,
1955
2447
  content
1956
2448
  }, execCtx);
1957
2449
  ctx.formatter.success("Environment variables updated.");
@@ -1980,7 +2472,26 @@ ${colors.bold("USAGE:")}
1980
2472
  ${colors.bold("forge env update")} - Update environment variables
1981
2473
 
1982
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
1983
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
1984
2495
  `);
1985
2496
  else console.log(`
1986
2497
  ${colors.bold("forge env")} - Manage environment variables
@@ -1995,7 +2506,9 @@ ${colors.bold("SUBCOMMANDS:")}
1995
2506
  ${colors.bold("OPTIONS:")}
1996
2507
  --server <id> Server ID (required)
1997
2508
  --site <id> Site ID (required)
2509
+ --file <path> Content file, - for stdin (for update)
1998
2510
  --content <str> New env content (for update)
2511
+ --check Compare with Forge, do not write (for update)
1999
2512
  -f, --format <fmt> Output format: json, human
2000
2513
 
2001
2514
  Run ${colors.cyan("forge env <subcommand> --help")} for details.
@@ -2266,19 +2779,32 @@ async function nginxGet(ctx) {
2266
2779
  }, ctx.formatter);
2267
2780
  }
2268
2781
  async function nginxUpdate(ctx) {
2782
+ const usage = "forge nginx update --server <server_id> --site <site_id> (--file <path|-> | --content <content>) [--check]";
2269
2783
  const server = String(ctx.options.server ?? "");
2270
2784
  const site = String(ctx.options.site ?? "");
2271
- const content = String(ctx.options.content ?? "");
2272
- if (!server) exitWithValidationError("server_id", "forge nginx update --server <server_id> --site <site_id> --content <content>", ctx.formatter);
2273
- if (!site) exitWithValidationError("site_id", "forge nginx update --server <server_id> --site <site_id> --content <content>", ctx.formatter);
2274
- if (!content) exitWithValidationError("content", "forge nginx update --server <server_id> --site <site_id> --content <content>", ctx.formatter);
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
+ });
2275
2793
  await runCommand(async () => {
2276
2794
  const token = ctx.getToken();
2277
2795
  const execCtx = ctx.createExecutorContext(token);
2278
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
+ }
2279
2805
  await updateNginxConfig({
2280
2806
  server_id,
2281
- site_id: await resolveSiteId(site, server_id, execCtx),
2807
+ site_id,
2282
2808
  content
2283
2809
  }, execCtx);
2284
2810
  ctx.formatter.success("Nginx configuration updated.");
@@ -2307,7 +2833,25 @@ ${colors.bold("USAGE:")}
2307
2833
  ${colors.bold("forge nginx update")} - Update nginx configuration
2308
2834
 
2309
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
2310
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
2311
2855
  `);
2312
2856
  else console.log(`
2313
2857
  ${colors.bold("forge nginx")} - Manage nginx configuration
@@ -2322,7 +2866,9 @@ ${colors.bold("SUBCOMMANDS:")}
2322
2866
  ${colors.bold("OPTIONS:")}
2323
2867
  --server <id> Server ID (required)
2324
2868
  --site <id> Site ID (required)
2869
+ --file <path> Content file, - for stdin (for update)
2325
2870
  --content <str> New nginx config (for update)
2871
+ --check Compare with Forge, do not write (for update)
2326
2872
 
2327
2873
  Run ${colors.cyan("forge nginx <subcommand> --help")} for details.
2328
2874
  `);
@@ -2512,6 +3058,328 @@ Run ${colors.cyan("forge nginx-templates <subcommand> --help")} for details.
2512
3058
  `);
2513
3059
  }
2514
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
2515
3383
  //#region src/commands/recipes/handlers.ts
2516
3384
  async function recipesList(ctx) {
2517
3385
  await runCommand(async () => {
@@ -3321,6 +4189,122 @@ Run ${colors.cyan("forge servers <subcommand> --help")} for subcommand details.
3321
4189
  `);
3322
4190
  }
3323
4191
  //#endregion
4192
+ //#region src/commands/services/handlers.ts
4193
+ /**
4194
+ * List services on a server with a derived availability flag.
4195
+ */
4196
+ async function servicesList(args, ctx) {
4197
+ const [server] = args;
4198
+ if (!server) exitWithValidationError("server", "forge services list <server>", ctx.formatter);
4199
+ await runCommand(async () => {
4200
+ const token = ctx.getToken();
4201
+ const execCtx = ctx.createExecutorContext(token);
4202
+ const result = await listServices({ server_id: await resolveServerId(server, execCtx) }, execCtx);
4203
+ ctx.formatter.outputList(result.data, [
4204
+ "service",
4205
+ "available",
4206
+ "detail"
4207
+ ], "No services.", (row) => {
4208
+ const status = row.available ? "yes" : "no";
4209
+ const detail = row.detail ? ` (${row.detail})` : "";
4210
+ return `${row.service.padEnd(12)}${status}${detail}`;
4211
+ });
4212
+ }, ctx.formatter);
4213
+ }
4214
+ /**
4215
+ * Restart a service on a server.
4216
+ */
4217
+ async function servicesRestart(args, ctx) {
4218
+ const [server, service] = args;
4219
+ if (!server) exitWithValidationError("server", "forge services restart <server> <service>", ctx.formatter);
4220
+ if (!service) exitWithValidationError("service", "forge services restart <server> <service>", ctx.formatter);
4221
+ const version = typeof ctx.options.version === "string" && ctx.options.version ? ctx.options.version : void 0;
4222
+ await runCommand(async () => {
4223
+ if (!RESTARTABLE_SERVICES.includes(service)) throw new ValidationError(`Invalid service "${service}"`, "service", [`Valid services: ${RESTARTABLE_SERVICES.join(", ")}`]);
4224
+ const token = ctx.getToken();
4225
+ const execCtx = ctx.createExecutorContext(token);
4226
+ const server_id = await resolveServerId(server, execCtx);
4227
+ let resolvedVersion = version;
4228
+ if (service === "php" && !resolvedVersion) {
4229
+ const { data: srv } = await getServer({ server_id }, execCtx);
4230
+ if (!srv.php_version) throw new ValidationError("The php service requires a version", "version", ["Pass --version <php_version> (e.g. --version php83)"]);
4231
+ resolvedVersion = srv.php_version;
4232
+ }
4233
+ await restartService({
4234
+ server_id,
4235
+ service,
4236
+ version: resolvedVersion
4237
+ }, execCtx);
4238
+ ctx.formatter.success(`${service} restart initiated on server ${server_id}.`);
4239
+ }, ctx.formatter);
4240
+ }
4241
+ //#endregion
4242
+ //#region src/commands/services/command.ts
4243
+ var handleServicesCommand = createCommandRouter({
4244
+ resource: "services",
4245
+ handlers: {
4246
+ list: [servicesList, "args"],
4247
+ ls: [servicesList, "args"],
4248
+ restart: [servicesRestart, "args"]
4249
+ },
4250
+ writeSubcommands: ["restart"]
4251
+ });
4252
+ //#endregion
4253
+ //#region src/commands/services/help.ts
4254
+ function showServicesHelp(subcommand) {
4255
+ if (subcommand === "list" || subcommand === "ls") console.log(`
4256
+ ${colors.bold("forge services list")} - List services on a server
4257
+
4258
+ ${colors.bold("USAGE:")}
4259
+ forge services list <server> [options]
4260
+
4261
+ ${colors.bold("ARGUMENTS:")}
4262
+ <server> Server ID or name (required)
4263
+
4264
+ ${colors.bold("NOTES:")}
4265
+ Availability is a heuristic derived from the server object — the API has
4266
+ no services listing endpoint.
4267
+
4268
+ ${colors.bold("EXAMPLES:")}
4269
+ forge services list 123
4270
+ forge services list my-server --format json
4271
+ `);
4272
+ else if (subcommand === "restart") console.log(`
4273
+ ${colors.bold("forge services restart")} - Restart a service on a server
4274
+
4275
+ ${colors.bold("USAGE:")}
4276
+ forge services restart <server> <service> [options]
4277
+
4278
+ ${colors.bold("ARGUMENTS:")}
4279
+ <server> Server ID or name (required)
4280
+ <service> One of: nginx, php, mysql, postgres, redis, supervisor
4281
+
4282
+ ${colors.bold("OPTIONS:")}
4283
+ --version <ver> PHP version (e.g. php83) — required for the php service
4284
+ (defaults to the server's php_version when omitted)
4285
+
4286
+ ${colors.bold("EXAMPLES:")}
4287
+ forge services restart 123 nginx
4288
+ forge services restart 123 php --version php83
4289
+ `);
4290
+ else console.log(`
4291
+ ${colors.bold("forge services")} - List and restart server services
4292
+
4293
+ ${colors.bold("USAGE:")}
4294
+ forge services <subcommand> [options]
4295
+
4296
+ ${colors.bold("SUBCOMMANDS:")}
4297
+ list, ls List services on a server
4298
+ restart <service> Restart a service (nginx, php, mysql, postgres, redis, supervisor)
4299
+
4300
+ ${colors.bold("OPTIONS:")}
4301
+ --version <ver> PHP version (for the php service)
4302
+ -f, --format <fmt> Output format: json, human, table
4303
+
4304
+ Run ${colors.cyan("forge services <subcommand> --help")} for details.
4305
+ `);
4306
+ }
4307
+ //#endregion
3324
4308
  //#region src/commands/sites/handlers.ts
3325
4309
  async function sitesList(ctx) {
3326
4310
  const server = String(ctx.options.server ?? "");
@@ -3661,14 +4645,285 @@ async function sshKeysGet(args, ctx) {
3661
4645
  }, ctx.formatter);
3662
4646
  }
3663
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
3664
4917
  //#region src/commands/ssh-keys/command.ts
3665
4918
  var handleSshKeysCommand = createCommandRouter({
3666
4919
  resource: "ssh-keys",
3667
4920
  handlers: {
3668
4921
  list: sshKeysList,
3669
4922
  ls: sshKeysList,
3670
- get: [sshKeysGet, "args"]
3671
- }
4923
+ get: [sshKeysGet, "args"],
4924
+ sync: sshKeysSync
4925
+ },
4926
+ writeSubcommands: ["sync"]
3672
4927
  });
3673
4928
  //#endregion
3674
4929
  //#region src/commands/ssh-keys/help.ts
@@ -3684,6 +4939,41 @@ ${colors.bold("forge ssh-keys get")} - Get SSH key details
3684
4939
 
3685
4940
  ${colors.bold("USAGE:")}
3686
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
3687
4977
  `);
3688
4978
  else console.log(`
3689
4979
  ${colors.bold("forge ssh-keys")} - Manage SSH keys
@@ -3694,9 +4984,11 @@ ${colors.bold("USAGE:")}
3694
4984
  ${colors.bold("SUBCOMMANDS:")}
3695
4985
  list, ls List SSH keys
3696
4986
  get <id> Get SSH key details
4987
+ sync Make the SSH keys of servers match a file
3697
4988
 
3698
4989
  ${colors.bold("OPTIONS:")}
3699
- --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)
3700
4992
  -f, --format <fmt> Output format: json, human, table
3701
4993
 
3702
4994
  Run ${colors.cyan("forge ssh-keys <subcommand> --help")} for details.
@@ -3746,6 +5038,26 @@ Run ${colors.cyan("forge user <subcommand> --help")} for details.
3746
5038
  }
3747
5039
  //#endregion
3748
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
+ }
3749
5061
  function parseArgs(argv = process.argv.slice(2)) {
3750
5062
  const options = {};
3751
5063
  const positional = [];
@@ -3754,24 +5066,22 @@ function parseArgs(argv = process.argv.slice(2)) {
3754
5066
  const arg = argv[i];
3755
5067
  if (arg.startsWith("--")) {
3756
5068
  const equalIndex = arg.indexOf("=");
3757
- if (equalIndex > -1) {
3758
- const key = arg.slice(2, equalIndex);
3759
- options[key] = arg.slice(equalIndex + 1);
3760
- } else {
5069
+ if (equalIndex > -1) setOption(options, arg.slice(2, equalIndex), arg.slice(equalIndex + 1));
5070
+ else {
3761
5071
  const key = arg.slice(2);
3762
5072
  const nextArg = argv[i + 1];
3763
- if (nextArg !== void 0 && !nextArg.startsWith("-")) {
3764
- options[key] = nextArg;
5073
+ if (nextArg !== void 0 && isOptionValue(nextArg)) {
5074
+ setOption(options, key, nextArg);
3765
5075
  i++;
3766
- } else options[key] = true;
5076
+ } else setOption(options, key, true);
3767
5077
  }
3768
5078
  } else if (arg.startsWith("-") && arg.length > 1) if (arg.length === 2) {
3769
5079
  const key = arg[1];
3770
5080
  const nextArg = argv[i + 1];
3771
- if (nextArg !== void 0 && !nextArg.startsWith("-")) {
3772
- options[key] = nextArg;
5081
+ if (nextArg !== void 0 && isOptionValue(nextArg)) {
5082
+ setOption(options, key, nextArg);
3773
5083
  i++;
3774
- } else options[key] = true;
5084
+ } else setOption(options, key, true);
3775
5085
  } else for (let j = 1; j < arg.length; j++) options[arg[j]] = true;
3776
5086
  else if (command.length < 2) command.push(arg);
3777
5087
  else positional.push(arg);
@@ -3782,9 +5092,17 @@ function parseArgs(argv = process.argv.slice(2)) {
3782
5092
  positional
3783
5093
  };
3784
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
+ }
3785
5103
  //#endregion
3786
5104
  //#region src/cli.ts
3787
- var VERSION = "0.4.5";
5105
+ var VERSION = "0.4.7";
3788
5106
  function showHelp() {
3789
5107
  console.log(`
3790
5108
  ${colors.bold("forge")} v${VERSION}
@@ -3810,6 +5128,9 @@ ${colors.bold("COMMANDS:")}
3810
5128
  deployments, d Manage deployments
3811
5129
  list, ls List deployments (requires --server --site)
3812
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]
3813
5134
 
3814
5135
  databases, db Manage databases
3815
5136
  list, ls List databases (requires --server)
@@ -3828,11 +5149,11 @@ ${colors.bold("COMMANDS:")}
3828
5149
 
3829
5150
  env Manage environment variables
3830
5151
  get Get env variables (requires --server --site)
3831
- update Update env variables (requires --server --site --content)
5152
+ update Update env variables (requires --server --site --file|--content) [--check]
3832
5153
 
3833
5154
  nginx Manage nginx configuration
3834
5155
  get Get nginx config (requires --server --site)
3835
- update Update nginx config (requires --server --site --content)
5156
+ update Update nginx config (requires --server --site --file|--content) [--check]
3836
5157
 
3837
5158
  certificates, certs Manage SSL certificates (per-domain in v2)
3838
5159
  get Get certificate for a domain (requires --server --site --domain)
@@ -3849,6 +5170,7 @@ ${colors.bold("COMMANDS:")}
3849
5170
  ssh-keys Manage SSH keys
3850
5171
  list, ls List SSH keys (requires --server)
3851
5172
  get <id> Get SSH key details (requires --server)
5173
+ sync Make server SSH keys match a file (requires --file, --server|--all-servers)
3852
5174
 
3853
5175
  backups Manage backup configurations
3854
5176
  list, ls List backup configurations (requires --server)
@@ -3867,6 +5189,24 @@ ${colors.bold("COMMANDS:")}
3867
5189
  create Create a scheduled job (requires --server --command)
3868
5190
  delete <id> Delete a scheduled job (requires --server)
3869
5191
 
5192
+ services List and restart server services
5193
+ list, ls List services on a server (forge services list <server>)
5194
+ restart <service> Restart a service (forge services restart <server> <service>)
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
+
3870
5210
  user Display authenticated user profile
3871
5211
  get Get the authenticated user profile
3872
5212
 
@@ -3948,14 +5288,15 @@ ${colors.bold("ENVIRONMENT VARIABLES:")}
3948
5288
  `);
3949
5289
  }
3950
5290
  async function main() {
3951
- const { command, options, positional } = parseArgs();
5291
+ const args = parseArgs();
5292
+ const { command, options, positional } = args;
3952
5293
  const [mainCommand, subcommand] = command;
3953
5294
  const wantsHelp = options.help !== void 0 || options.h !== void 0;
3954
5295
  if (wantsHelp && !mainCommand) {
3955
5296
  showHelp();
3956
5297
  process.exit(0);
3957
5298
  }
3958
- if (options.version || options.v) {
5299
+ if (isVersionRequest(args)) {
3959
5300
  console.log(VERSION);
3960
5301
  process.exit(0);
3961
5302
  }
@@ -4083,6 +5424,20 @@ async function main() {
4083
5424
  }
4084
5425
  await handleScheduledJobsCommand(subcommand ?? "list", positional, options);
4085
5426
  break;
5427
+ case "services":
5428
+ if (wantsHelp) {
5429
+ showServicesHelp(subcommand);
5430
+ process.exit(0);
5431
+ }
5432
+ await handleServicesCommand(subcommand ?? "list", positional, options);
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;
4086
5441
  case "user":
4087
5442
  if (wantsHelp) {
4088
5443
  showUserHelp(subcommand);