@studiometa/forge-cli 0.4.6 → 0.4.8

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