@studiometa/forge-cli 0.4.6 → 0.4.7

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