@sealant/mend 0.36.0-next.665 → 0.36.0-next.675

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 (92) hide show
  1. package/dist/{chunk-K5WNRH4V.js → chunk-TP4A72RF.js} +30 -2
  2. package/dist/{dashboard-DQB5RNXJ.js → dashboard-RAZDVFR7.js} +8 -2
  3. package/dist/main.js +1356 -158
  4. package/man/mend-accounts.1 +1 -1
  5. package/man/mend-adopt.1 +1 -1
  6. package/man/mend-attach.1 +1 -1
  7. package/man/mend-codex.1 +1 -1
  8. package/man/mend-completions.1 +1 -1
  9. package/man/mend-connect-pi.1 +1 -1
  10. package/man/mend-connect.1 +1 -1
  11. package/man/mend-continue.1 +1 -1
  12. package/man/mend-doctor.1 +1 -1
  13. package/man/mend-dotfiles-repo.1 +1 -1
  14. package/man/mend-dotfiles-sync.1 +1 -1
  15. package/man/mend-dotfiles.1 +1 -1
  16. package/man/mend-env-cluster.1 +1 -1
  17. package/man/mend-env-load.1 +1 -1
  18. package/man/mend-env-show.1 +1 -1
  19. package/man/mend-folder-create.1 +1 -1
  20. package/man/mend-folder-list.1 +1 -1
  21. package/man/mend-folder-push.1 +1 -1
  22. package/man/mend-folder-rm.1 +1 -1
  23. package/man/mend-git-author.1 +1 -1
  24. package/man/mend-help.1 +1 -1
  25. package/man/mend-invite.1 +1 -1
  26. package/man/mend-keys-init.1 +1 -1
  27. package/man/mend-keys-mode.1 +1 -1
  28. package/man/mend-keys-share.1 +1 -1
  29. package/man/mend-keys-show.1 +1 -1
  30. package/man/mend-land.1 +1 -1
  31. package/man/mend-login.1 +1 -1
  32. package/man/mend-logout.1 +1 -1
  33. package/man/mend-logs.1 +13 -2
  34. package/man/mend-man.1 +1 -1
  35. package/man/mend-members.1 +1 -1
  36. package/man/mend-memory-import.1 +1 -1
  37. package/man/mend-memory-rm.1 +1 -1
  38. package/man/mend-memory-show.1 +1 -1
  39. package/man/mend-memory.1 +1 -1
  40. package/man/mend-models.1 +1 -1
  41. package/man/mend-operator-exposure.1 +1 -1
  42. package/man/mend-operator-gate.1 +1 -1
  43. package/man/mend-operator-grant-owner.1 +1 -1
  44. package/man/mend-operator-org-create.1 +1 -1
  45. package/man/mend-operator-org-invite-owner.1 +1 -1
  46. package/man/mend-operator-org-list.1 +1 -1
  47. package/man/mend-operator-org-rename.1 +1 -1
  48. package/man/mend-operator-reset-link.1 +1 -1
  49. package/man/mend-pair.1 +1 -1
  50. package/man/mend-projects.1 +1 -1
  51. package/man/mend-pull.1 +1 -1
  52. package/man/mend-refresh.1 +1 -1
  53. package/man/mend-rejoin.1 +1 -1
  54. package/man/mend-resume.1 +1 -1
  55. package/man/mend-run.1 +2 -2
  56. package/man/mend-secrets-add.1 +1 -1
  57. package/man/mend-secrets-rm.1 +1 -1
  58. package/man/mend-secrets.1 +1 -1
  59. package/man/mend-server-logs.1 +1 -1
  60. package/man/mend-server-restart.1 +1 -1
  61. package/man/mend-server-setup.1 +32 -2
  62. package/man/mend-server-start.1 +1 -1
  63. package/man/mend-server-status.1 +4 -2
  64. package/man/mend-server-stop.1 +2 -2
  65. package/man/mend-server-upgrade.1 +1 -1
  66. package/man/mend-server.1 +1 -1
  67. package/man/mend-service-add.1 +1 -1
  68. package/man/mend-service-connect.1 +1 -1
  69. package/man/mend-service-init.1 +1 -1
  70. package/man/mend-service-list.1 +10 -4
  71. package/man/mend-service-logs.1 +1 -1
  72. package/man/mend-service-restart.1 +1 -1
  73. package/man/mend-service-run.1 +13 -6
  74. package/man/mend-service-stop.1 +1 -1
  75. package/man/mend-session-share.1 +1 -1
  76. package/man/mend-sessions.1 +1 -1
  77. package/man/mend-shell.1 +1 -1
  78. package/man/mend-skills-push.1 +1 -1
  79. package/man/mend-skills.1 +1 -1
  80. package/man/mend-snake.1 +1 -1
  81. package/man/mend-ssh-setup.1 +1 -1
  82. package/man/mend-ssh.1 +1 -1
  83. package/man/mend-stop.1 +1 -1
  84. package/man/mend-ui.1 +1 -1
  85. package/man/mend-uninstall.1 +1 -1
  86. package/man/mend-version.1 +1 -1
  87. package/man/mend-wait.1 +5 -5
  88. package/man/mend-workspace-replace.1 +1 -1
  89. package/man/mend-worktrees-rm.1 +1 -1
  90. package/man/mend-worktrees.1 +1 -1
  91. package/man/mend.1 +3 -3
  92. package/package.json +2 -2
package/dist/main.js CHANGED
@@ -40,6 +40,7 @@ import {
40
40
  landingFactsFromWire,
41
41
  launcherNameOf,
42
42
  matchProjectByCwd,
43
+ mayStillBeWorking,
43
44
  narrowCredential,
44
45
  noAnswerError,
45
46
  normalizeGitAuthor,
@@ -51,6 +52,7 @@ import {
51
52
  replaceConfirmLines,
52
53
  repositoryCloneUrlIssue,
53
54
  sameGrant,
55
+ serviceStartCorrelation,
54
56
  servicesHoldOf,
55
57
  sessionDisplayName,
56
58
  sessionWorkspaceLines,
@@ -68,10 +70,11 @@ import {
68
70
  workspaceRetirementLine,
69
71
  worktreeJoinLine,
70
72
  worktreeListsPeople
71
- } from "./chunk-K5WNRH4V.js";
73
+ } from "./chunk-TP4A72RF.js";
72
74
 
73
75
  // src/main.ts
74
76
  import { spawn as spawn3, spawnSync as spawnSync6 } from "node:child_process";
77
+ import * as crypto from "node:crypto";
75
78
  import * as fs20 from "node:fs";
76
79
  import { createRequire } from "node:module";
77
80
  import * as net5 from "node:net";
@@ -853,6 +856,421 @@ var EDGE_CADDYFILE = [
853
856
  "}"
854
857
  ].join("\n") + "\n";
855
858
 
859
+ // src/server-mirrors.ts
860
+ var NPM_MIRROR_IMAGE = "nginx:1.29-alpine";
861
+ var DOCKER_MIRROR_IMAGE = "registry:3.1";
862
+ var MIRRORS_COMPOSE_FILE = "compose.mirrors.yaml";
863
+ var NPM_MIRROR_CONF_NAME = "npm-mirror.conf";
864
+ var NPM_MIRROR_URL = "http://npm-mirror:4873/";
865
+ var DOCKER_MIRROR_URL = "http://docker-mirror:5000";
866
+ var DOCKER_MIRROR_CONTAINER = "mend-docker-mirror";
867
+ var DEFAULT_NPM_MIRROR_MAX_SIZE = "10g";
868
+ var DOCKER_MIRROR_TTL = "168h";
869
+ var DEFAULT_DOCKER_MIRROR_MAX_SIZE = "20g";
870
+ var MIRROR_MIN_FREE = "5g";
871
+ var DOCKER_MIRROR_GUARD_NAME = "docker-mirror-guard.sh";
872
+ var dockerMirrorLogin = (docker2) => docker2 !== null && "upstreamUser" in docker2 ? docker2.upstreamUser : void 0;
873
+ var DEFAULT_MIRRORS = {
874
+ npm: { maxSize: DEFAULT_NPM_MIRROR_MAX_SIZE },
875
+ docker: { maxSize: DEFAULT_DOCKER_MIRROR_MAX_SIZE }
876
+ };
877
+ var parseMirrorSize = (value) => {
878
+ const match = /^([1-9]\d{0,5})([mMgG])$/.exec(value.trim());
879
+ if (match === null) return null;
880
+ const [, amount = "", unit = ""] = match;
881
+ const size = `${amount}${unit.toLowerCase()}`;
882
+ return mirrorSizeBytes(size) >= 1024 ** 3 ? size : null;
883
+ };
884
+ var mirrorSizeBytes = (size) => {
885
+ const amount = Number(size.slice(0, -1));
886
+ return size.endsWith("g") ? amount * 1024 ** 3 : amount * 1024 ** 2;
887
+ };
888
+ var CREDENTIAL = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,254}$/;
889
+ var isDockerHubCredential = (value) => CREDENTIAL.test(value);
890
+ var runsMirrors = (mirrors) => mirrors !== void 0 && (mirrors.npm !== null || mirrors.docker !== null);
891
+ var mirrorsEnvLines = (mirrors, dockerHubToken) => {
892
+ const user = dockerMirrorLogin(mirrors?.docker ?? null);
893
+ return [
894
+ ...mirrors === void 0 || mirrors.npm === null ? [] : [`MEND_NPM_MIRROR_MAX_SIZE=${mirrors.npm.maxSize}`],
895
+ ...mirrors === void 0 || mirrors.docker === null ? [] : [`MEND_DOCKER_MIRROR_MAX_SIZE=${mirrors.docker.maxSize}`],
896
+ ...user === void 0 || dockerHubToken === void 0 ? [] : [`MEND_DOCKER_HUB_USERNAME=${user}`, `MEND_DOCKER_HUB_TOKEN=${dockerHubToken}`]
897
+ ];
898
+ };
899
+ var LOGGING = [
900
+ " logging:",
901
+ " driver: json-file",
902
+ " options:",
903
+ ' max-size: "20m"',
904
+ ' max-file: "5"'
905
+ ];
906
+ var HEALTH_TIMING = [
907
+ " interval: 10s",
908
+ " timeout: 5s",
909
+ " retries: 3",
910
+ " start_period: 10s"
911
+ ];
912
+ var npmMirrorService = () => [
913
+ " # Read-through cache of registry.npmjs.org (npm-mirror.conf). Size-capped, and it leaves",
914
+ ` # ${MIRROR_MIN_FREE} free on its disk; the least recently used tarballs go first. No credential sent.`,
915
+ " npm-mirror:",
916
+ ` image: ${NPM_MIRROR_IMAGE}`,
917
+ " hostname: npm-mirror",
918
+ " restart: unless-stopped",
919
+ " environment:",
920
+ ' NGINX_ENTRYPOINT_LOCAL_RESOLVERS: "1"',
921
+ ' NGINX_ENTRYPOINT_QUIET_LOGS: "1"',
922
+ " NPM_MIRROR_MAX_SIZE: ${MEND_NPM_MIRROR_MAX_SIZE:?set MEND_NPM_MIRROR_MAX_SIZE in server.env}",
923
+ ` NPM_MIRROR_MIN_FREE: ${MIRROR_MIN_FREE}`,
924
+ " volumes:",
925
+ ` - ./${NPM_MIRROR_CONF_NAME}:/etc/nginx/templates/default.conf.template:ro`,
926
+ " - mend-npm-mirror:/var/cache/npm-mirror",
927
+ " # Answered by nginx itself, so it says the mirror is up without asking the registry.",
928
+ " healthcheck:",
929
+ ' test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:4873/-/ping"]',
930
+ ...HEALTH_TIMING,
931
+ ...LOGGING
932
+ ];
933
+ var dockerMirrorService = (mirrors) => [
934
+ " # Pull-through cache of Docker Hub, run by its guard (docker-mirror-guard.sh): the cache stays",
935
+ ` # under its cap and leaves ${MIRROR_MIN_FREE} free on its disk. Layer data is deleted seven days after a`,
936
+ " # fetch. The metrics listener stays on the container's loopback.",
937
+ " docker-mirror:",
938
+ ` image: ${DOCKER_MIRROR_IMAGE}`,
939
+ ` container_name: ${DOCKER_MIRROR_CONTAINER}`,
940
+ " hostname: docker-mirror",
941
+ " restart: unless-stopped",
942
+ " init: true",
943
+ ` entrypoint: ["/bin/sh", "/mend/${DOCKER_MIRROR_GUARD_NAME}"]`,
944
+ " environment:",
945
+ " DOCKER_MIRROR_MAX_SIZE:",
946
+ " ${MEND_DOCKER_MIRROR_MAX_SIZE:?set MEND_DOCKER_MIRROR_MAX_SIZE in server.env}",
947
+ ` DOCKER_MIRROR_MIN_FREE: ${MIRROR_MIN_FREE}`,
948
+ " REGISTRY_PROXY_REMOTEURL: https://registry-1.docker.io",
949
+ ` REGISTRY_PROXY_TTL: ${DOCKER_MIRROR_TTL}`,
950
+ " REGISTRY_LOG_LEVEL: warn",
951
+ ' REGISTRY_LOG_ACCESSLOG_DISABLED: "true"',
952
+ " REGISTRY_HTTP_DEBUG_ADDR: 127.0.0.1:5001",
953
+ ' REGISTRY_HTTP_DEBUG_PROMETHEUS_ENABLED: "true"',
954
+ ...dockerMirrorLogin(mirrors.docker) === void 0 ? [] : [
955
+ " # A token the operator declared Public Repo Read-only: every session can pull what it reads.",
956
+ " REGISTRY_PROXY_USERNAME: ${MEND_DOCKER_HUB_USERNAME:?set MEND_DOCKER_HUB_USERNAME in server.env}",
957
+ " REGISTRY_PROXY_PASSWORD: ${MEND_DOCKER_HUB_TOKEN:?set MEND_DOCKER_HUB_TOKEN in server.env}"
958
+ ],
959
+ " volumes:",
960
+ ` - ./${DOCKER_MIRROR_GUARD_NAME}:/mend/${DOCKER_MIRROR_GUARD_NAME}:ro`,
961
+ " - mend-docker-mirror:/var/lib/registry",
962
+ " # Healthy while its guard keeps working, which it records every pass. The registry inside may",
963
+ " # be paused for want of disk, or not started because Docker Hub did not answer (it asks Hub as",
964
+ " # it starts, and the guard tries again every pass): setup does not wait for it.",
965
+ " healthcheck:",
966
+ ` test: ["CMD-SHELL", 'test -n "$$(find /tmp/mend-mirror-guard -mmin -2)"']`,
967
+ ...HEALTH_TIMING,
968
+ ...LOGGING
969
+ ];
970
+ var renderMirrorsOverlay = (mirrors) => {
971
+ if (!runsMirrors(mirrors)) return void 0;
972
+ return [
973
+ "# Written by mend server setup: the package and image mirrors this install runs, on the Compose",
974
+ "# network only (no host port). Values come from server.env. Sessions install npm packages and",
975
+ "# pull Docker Hub images through them; a mirror that is down sends them to the registry itself.",
976
+ "services:",
977
+ " mend:",
978
+ " environment:",
979
+ ...mirrors.npm === null ? [] : [` MEND_NPM_MIRROR_URL: ${NPM_MIRROR_URL}`],
980
+ ...mirrors.docker === null ? [] : [
981
+ ` SEALANT_DOCKER_REGISTRY_MIRRORS: ${DOCKER_MIRROR_URL}`,
982
+ ` SEALANT_DOCKER_REGISTRY_MIRROR_CONTAINER: ${DOCKER_MIRROR_CONTAINER}`
983
+ ],
984
+ "",
985
+ ...mirrors.npm === null ? [] : [...npmMirrorService(), ""],
986
+ ...mirrors.docker === null ? [] : [...dockerMirrorService(mirrors), ""],
987
+ "volumes:",
988
+ ...mirrors.npm === null ? [] : [" mend-npm-mirror:"],
989
+ ...mirrors.docker === null ? [] : [" mend-docker-mirror:"],
990
+ ""
991
+ ].join("\n");
992
+ };
993
+ var mirrorServices = (mirrors) => [
994
+ ...mirrors === void 0 || mirrors.npm === null ? [] : ["npm-mirror"],
995
+ ...mirrors === void 0 || mirrors.docker === null ? [] : ["docker-mirror"]
996
+ ];
997
+ var mirrorImagesOf = (overlay) => [...overlay.matchAll(/^ {4}image:[ \t]*((?:nginx|registry):[^\s#]+)[ \t]*$/gm)].map((match) => match[1]).filter((image) => image !== void 0);
998
+ var NPM_MIRROR_CONF = `# Mend's npm mirror: a read-through cache of the public npm registry (registry.npmjs.org) for the
999
+ # workspaces on this host. nginx keeps what it fetched and serves it again. The package manager
1000
+ # checks every tarball against its lockfile's integrity hash, so the cache never decides what is
1001
+ # installed. Nothing is published through it, and no credential is sent upstream. When the
1002
+ # registry fails, a copy already held is served instead.
1003
+ proxy_cache_path /var/cache/npm-mirror levels=2:2 keys_zone=npm:64m
1004
+ max_size=\${NPM_MIRROR_MAX_SIZE} min_free=\${NPM_MIRROR_MIN_FREE}
1005
+ inactive=90d use_temp_path=off;
1006
+
1007
+ # Abbreviated metadata (what installs ask for) and full metadata are different documents.
1008
+ map $http_accept $npm_document {
1009
+ "~application/vnd\\.npm\\.install-v1\\+json" abbreviated;
1010
+ default full;
1011
+ }
1012
+
1013
+ # One JSON line per request: \`mend server status\` reads the cache field of the tarball lines.
1014
+ log_format npm_mirror escape=json
1015
+ '{"time":"$time_iso8601","method":"$request_method","uri":"$uri","status":$status,'
1016
+ '"cache":"$upstream_cache_status","bytes":$body_bytes_sent}';
1017
+
1018
+ server {
1019
+ listen 4873;
1020
+ access_log /dev/stdout npm_mirror;
1021
+ error_log /dev/stderr warn;
1022
+ client_max_body_size 8m;
1023
+
1024
+ resolver \${NGINX_LOCAL_RESOLVERS} valid=60s ipv6=off;
1025
+ resolver_timeout 5s;
1026
+ set $npm_registry registry.npmjs.org;
1027
+
1028
+ proxy_http_version 1.1;
1029
+ proxy_set_header Host registry.npmjs.org;
1030
+ proxy_set_header Connection "";
1031
+ # Never forward a credential: the mirror serves the public registry, the same to everyone.
1032
+ proxy_set_header Authorization "";
1033
+ proxy_set_header Cookie "";
1034
+ # Uncompressed from upstream, so one cached copy serves every client; gzip below compresses it.
1035
+ proxy_set_header Accept-Encoding "";
1036
+ proxy_ssl_server_name on;
1037
+ proxy_ssl_name registry.npmjs.org;
1038
+ proxy_ssl_verify on;
1039
+ proxy_ssl_verify_depth 4;
1040
+ proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
1041
+ proxy_connect_timeout 10s;
1042
+ proxy_read_timeout 60s;
1043
+ proxy_ignore_headers Cache-Control Expires Set-Cookie Vary;
1044
+ proxy_hide_header Set-Cookie;
1045
+ proxy_cache_lock on;
1046
+ proxy_cache_lock_timeout 120s;
1047
+ proxy_cache_use_stale error timeout invalid_header updating http_500 http_502 http_503 http_504 http_429;
1048
+ proxy_cache_background_update on;
1049
+ add_header X-Mend-Mirror $upstream_cache_status always;
1050
+
1051
+ gzip on;
1052
+ gzip_proxied any;
1053
+ gzip_types application/json application/vnd.npm.install-v1+json;
1054
+
1055
+ # Answered here, so a client can tell the mirror is up without asking the registry.
1056
+ location = /-/ping {
1057
+ default_type application/json;
1058
+ return 200 '{}';
1059
+ }
1060
+
1061
+ # Tarballs never change once published: kept until the cache needs the room.
1062
+ location ~ ^/(?:@[^/]+/)?[^/]+/-/[^/]+\\.tgz$ {
1063
+ limit_except GET HEAD { deny all; }
1064
+ proxy_cache npm;
1065
+ proxy_cache_key $uri;
1066
+ proxy_cache_valid 200 365d;
1067
+ proxy_pass https://$npm_registry;
1068
+ }
1069
+
1070
+ # Audit reports go to the registry as they are, never cached.
1071
+ location /-/npm/v1/security/ {
1072
+ limit_except POST { deny all; }
1073
+ proxy_pass https://$npm_registry;
1074
+ }
1075
+
1076
+ # Metadata: fresh for five minutes, then revalidated; served stale while the registry fails.
1077
+ # The key is the whole request, query included: a search for one package never answers another.
1078
+ location / {
1079
+ limit_except GET HEAD { deny all; }
1080
+ proxy_cache npm;
1081
+ proxy_cache_key $request_uri|$npm_document;
1082
+ proxy_cache_valid 200 5m;
1083
+ proxy_cache_valid 404 1m;
1084
+ proxy_cache_revalidate on;
1085
+ proxy_pass https://$npm_registry;
1086
+ }
1087
+ }
1088
+ `;
1089
+ var DOCKER_MIRROR_GUARD = `#!/bin/sh
1090
+ # Mend's Docker mirror guard (written by mend server setup): runs the registry, and keeps its cache
1091
+ # under a byte cap and the disk it lives on above a floor. Over the cap, the cache is cleared and
1092
+ # fills again from Docker Hub. Below the floor, the cache is cleared and the registry stays stopped
1093
+ # until there is room again; meanwhile session Docker daemons pull from Docker Hub directly. The
1094
+ # registry has no size cap of its own, and its seven-day expiry deletes layer data only.
1095
+ set -u
1096
+ root=/var/lib/registry
1097
+ state=/tmp/mend-mirror-guard
1098
+ size_mb() {
1099
+ case "$1" in
1100
+ *g) echo $((\${1%g} * 1024)) ;;
1101
+ *m) echo "\${1%m}" ;;
1102
+ *) echo "mend docker mirror guard: cannot read size $1" >&2 && exit 64 ;;
1103
+ esac
1104
+ }
1105
+ cap=$(size_mb "\${DOCKER_MIRROR_MAX_SIZE:?}") || exit 64
1106
+ floor=$(size_mb "\${DOCKER_MIRROR_MIN_FREE:?}") || exit 64
1107
+ interval=\${DOCKER_MIRROR_GUARD_INTERVAL:-30}
1108
+ pid=
1109
+ stop_registry() {
1110
+ if [ -n "$pid" ]; then
1111
+ kill "$pid" 2>/dev/null
1112
+ wait "$pid" 2>/dev/null
1113
+ fi
1114
+ pid=
1115
+ }
1116
+ clear_cache() { rm -rf "$root/docker" "$root/scheduler-state.json"; }
1117
+ cache_held() { [ -e "$root/docker" ] || [ -e "$root/scheduler-state.json" ]; }
1118
+ trap 'stop_registry; exit 0' TERM INT
1119
+ while :; do
1120
+ free=$(df -Pm "$root" | awk 'NR == 2 { print $4 }')
1121
+ if [ "$free" -lt "$floor" ]; then
1122
+ # Below the floor: no registry, and no cache, whether the registry was running or this is a
1123
+ # start with a cache left from before. Once the space is back, the next pass starts it again.
1124
+ stop_registry
1125
+ # What is left, looked at again after clearing: the log and the status say what this finds,
1126
+ # not what was attempted.
1127
+ if cache_held; then
1128
+ clear_cache
1129
+ if cache_held; then outcome="cache could not be cleared"; else outcome="cache cleared"; fi
1130
+ echo "mend docker mirror guard: \${free} MiB free on its disk, below \${floor} MiB: \${outcome}, registry paused" >&2
1131
+ fi
1132
+ if cache_held; then held=kept; else held=none; fi
1133
+ echo "paused \${free} \${floor} \${held}" >"$state"
1134
+ else
1135
+ used=$(du -sm "$root" | cut -f1)
1136
+ if [ "$used" -gt "$cap" ]; then
1137
+ stop_registry
1138
+ clear_cache
1139
+ if cache_held; then outcome="could not be cleared"; else outcome="cleared"; fi
1140
+ echo "mend docker mirror guard: cache \${used} MiB, over its \${cap} MiB cap: \${outcome}" >&2
1141
+ fi
1142
+ if [ -z "$pid" ] || ! kill -0 "$pid" 2>/dev/null; then
1143
+ registry serve /etc/distribution/config.yml &
1144
+ pid=$!
1145
+ fi
1146
+ echo "running" >"$state"
1147
+ fi
1148
+ sleep "$interval" &
1149
+ wait $!
1150
+ done
1151
+ `;
1152
+ var formatBytes = (bytes) => {
1153
+ if (bytes < 1024) return `${bytes} B`;
1154
+ const units = ["KiB", "MiB", "GiB", "TiB"];
1155
+ let value = bytes / 1024;
1156
+ let unit = 0;
1157
+ while (value >= 1024 && unit < units.length - 1) {
1158
+ value /= 1024;
1159
+ unit += 1;
1160
+ }
1161
+ return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`;
1162
+ };
1163
+ var percent = (part, whole) => whole === 0 ? "" : ` (${Math.round(part / whole * 100)}%)`;
1164
+ var mirrorDiskProbe = (directory) => `du -sk ${directory} | cut -f1; df -Pk ${directory} | awk 'NR == 2 { print $4 }'; cat /tmp/mend-mirror-guard 2>/dev/null || true`;
1165
+ var mirrorDiskOf = (stdout) => {
1166
+ const [used, free, guard = ""] = stdout.trim().split("\n").map((line) => line.trim());
1167
+ const usedKiB = Number(used);
1168
+ const freeKiB = Number(free);
1169
+ if (used === void 0 || free === void 0 || used === "" || free === "") return null;
1170
+ if (!Number.isFinite(usedKiB) || !Number.isFinite(freeKiB)) return null;
1171
+ const paused = /^paused (\d+) (\d+)(?: (none|kept))?$/.exec(guard);
1172
+ return {
1173
+ used: usedKiB * 1024,
1174
+ free: freeKiB * 1024,
1175
+ guard: paused?.[1] !== void 0 && paused[2] !== void 0 ? {
1176
+ state: "paused",
1177
+ freeMiB: Number(paused[1]),
1178
+ floorMiB: Number(paused[2]),
1179
+ cache: paused[3] === "none" || paused[3] === "kept" ? paused[3] : null
1180
+ } : guard === "running" ? { state: "running" } : null
1181
+ };
1182
+ };
1183
+ var FROM_CACHE = /* @__PURE__ */ new Set(["HIT", "STALE", "UPDATING", "REVALIDATED"]);
1184
+ var FROM_UPSTREAM = /* @__PURE__ */ new Set(["MISS", "EXPIRED", "BYPASS"]);
1185
+ var npmMirrorTraffic = (log) => {
1186
+ let tarballs = 0;
1187
+ let fromCache = 0;
1188
+ let fromRegistry = 0;
1189
+ for (const line of log.split("\n")) {
1190
+ const start = line.indexOf("{");
1191
+ if (start < 0) continue;
1192
+ let fields;
1193
+ try {
1194
+ fields = JSON.parse(line.slice(start));
1195
+ } catch {
1196
+ continue;
1197
+ }
1198
+ if (typeof fields !== "object" || fields === null) continue;
1199
+ const entry = new Map(Object.entries(fields));
1200
+ const uri = entry.get("uri");
1201
+ const cache = entry.get("cache");
1202
+ if (typeof uri !== "string" || !uri.endsWith(".tgz") || typeof cache !== "string") continue;
1203
+ tarballs += 1;
1204
+ if (FROM_CACHE.has(cache)) fromCache += 1;
1205
+ else if (FROM_UPSTREAM.has(cache)) fromRegistry += 1;
1206
+ }
1207
+ return { tarballs, fromCache, fromRegistry };
1208
+ };
1209
+ var dockerMirrorTraffic = (metrics) => {
1210
+ const counter = (name, type2) => {
1211
+ const match = new RegExp(`^registry_proxy_${name}_total\\{type="${type2}"\\} (\\d+)`, "m").exec(
1212
+ metrics
1213
+ );
1214
+ return match?.[1] === void 0 ? null : Number(match[1]);
1215
+ };
1216
+ const values = [
1217
+ counter("hits", "blob"),
1218
+ counter("misses", "blob"),
1219
+ counter("hits", "manifest"),
1220
+ counter("misses", "manifest")
1221
+ ];
1222
+ const [blobHits, blobMisses, manifestHits, manifestMisses] = values;
1223
+ if (blobHits === void 0 || blobHits === null || blobMisses === void 0 || blobMisses === null || manifestHits === void 0 || manifestHits === null || manifestMisses === void 0 || manifestMisses === null) {
1224
+ return null;
1225
+ }
1226
+ return {
1227
+ blobs: { hits: blobHits, misses: blobMisses },
1228
+ manifests: { hits: manifestHits, misses: manifestMisses }
1229
+ };
1230
+ };
1231
+ var diskWords = (disk, cap) => typeof disk === "string" ? `size not read: ${disk} \xB7 cap ${formatBytes(mirrorSizeBytes(cap))}` : `${formatBytes(disk.used)} cached of ${formatBytes(mirrorSizeBytes(cap))} \xB7 ${formatBytes(disk.free)} free on its disk`;
1232
+ var observedNpmMirrorLine = (mirrors, observed) => {
1233
+ if (mirrors.npm === null)
1234
+ return "npm mirror \xB7 off on this install \xB7 mend server setup --npm-mirror turns it on";
1235
+ if (!observed.running)
1236
+ return "npm mirror \xB7 container not running \xB7 sessions install from registry.npmjs.org directly";
1237
+ const { traffic } = observed;
1238
+ const seen = typeof traffic === "string" ? `traffic not read: ${traffic}` : traffic.tarballs === 0 ? "no tarball requests in its log for the last 24 h" : `last 24 h: ${traffic.tarballs} tarball requests \xB7 ${traffic.fromCache} served from the cache${percent(traffic.fromCache, traffic.tarballs)} \xB7 ${traffic.fromRegistry} fetched from registry.npmjs.org`;
1239
+ return [
1240
+ "npm mirror \xB7 running",
1241
+ diskWords(observed.disk, mirrors.npm.maxSize),
1242
+ seen,
1243
+ "observed"
1244
+ ].join(" \xB7 ");
1245
+ };
1246
+ var observedDockerMirrorLine = (mirrors, observed) => {
1247
+ if (mirrors.docker === null)
1248
+ return "docker mirror \xB7 off on this install \xB7 mend server setup --docker-mirror turns it on";
1249
+ if (!observed.running)
1250
+ return "docker mirror \xB7 container not running \xB7 session Docker daemons pull from Docker Hub directly";
1251
+ const { disk, traffic } = observed;
1252
+ if (typeof disk !== "string" && disk.guard?.state === "paused")
1253
+ return [
1254
+ "docker mirror \xB7 paused by its disk guard",
1255
+ `${formatBytes(disk.guard.freeMiB * 1024 ** 2)} free on its disk, below ${formatBytes(disk.guard.floorMiB * 1024 ** 2)}`,
1256
+ disk.guard.cache === "none" ? "no cache held" : disk.guard.cache === "kept" ? "its cache could not be cleared" : "the guard did not say whether its cache was cleared",
1257
+ "session Docker daemons pull from Docker Hub directly until there is room",
1258
+ "observed"
1259
+ ].join(" \xB7 ");
1260
+ const since = observed.startedAt === null ? "since it started" : `since ${observed.startedAt}`;
1261
+ const seen = typeof traffic === "string" ? `traffic not read: ${traffic}` : `${since}: layers ${traffic.blobs.hits + traffic.blobs.misses} requested \xB7 ${traffic.blobs.hits} from the cache${percent(traffic.blobs.hits, traffic.blobs.hits + traffic.blobs.misses)} \xB7 manifests ${traffic.manifests.hits + traffic.manifests.misses} \xB7 ${traffic.manifests.hits} from the cache`;
1262
+ const user = dockerMirrorLogin(mirrors.docker);
1263
+ const login2 = user === void 0 ? "pulls from Docker Hub anonymously" : `pulls from Docker Hub as ${user}, with a token the operator declared Public Repo Read-only \xB7 every session can pull whatever that token can read`;
1264
+ return [
1265
+ "docker mirror \xB7 running",
1266
+ diskWords(disk, mirrors.docker.maxSize),
1267
+ "layers evicted 7 days after each fetch",
1268
+ seen,
1269
+ login2,
1270
+ "observed"
1271
+ ].join(" \xB7 ");
1272
+ };
1273
+
856
1274
  // src/server-edge.ts
857
1275
  var EXPOSURES = ["loopback", "private", "public"];
858
1276
  var TENANCIES = ["single", "multi"];
@@ -909,7 +1327,8 @@ var renderPostureOverlay = (posture) => {
909
1327
  };
910
1328
  var composeOverlays = (posture) => [
911
1329
  ...posture.edgeHost === void 0 ? [] : [EDGE_COMPOSE_FILE],
912
- ...renderPostureOverlay(posture) === void 0 ? [] : [POSTURE_COMPOSE_FILE]
1330
+ ...renderPostureOverlay(posture) === void 0 ? [] : [POSTURE_COMPOSE_FILE],
1331
+ ...runsMirrors(posture.mirrors) ? [MIRRORS_COMPOSE_FILE] : []
913
1332
  ];
914
1333
  var record = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? new Map(Object.entries(value)) : null;
915
1334
  var healthPosture = (body) => {
@@ -1980,7 +2399,7 @@ var COMMANDS = [
1980
2399
  "The same worktree, record, and review as mend codex, with a command of your own in place of a harness. Everything after -- is the command.",
1981
2400
  "The command's output is printed as the record has it, and mend run exits with the command's exit code. It runs in a terminal, so stdout and stderr arrive together, on stdout. What Mend says itself goes to stderr, so out=$(mend run -- git log -1) holds the command's output and nothing else. Ctrl+C stops watching and puts the terminal back; the command keeps running, and mend logs and mend wait pick it up again. A signal exits 128 + its number: 130 for SIGINT (Ctrl+C), 129 for SIGHUP, 143 for SIGTERM. A second signal exits at once, and an exit waits at most 5 seconds for a reader that takes nothing, then says the output may be incomplete.",
1982
2401
  "Output this terminal could not be given in full (a read the server refused, a reader that went away) fails mend run with exit 1 even when the command succeeded, and the command's own code is said on stderr: a script never takes cut output for the whole of it.",
1983
- "The platform takes at most 64 words, none empty and none starting or ending with whitespace (a script that starts with a newline, for example). Such a command is refused before anything is created; trim the word and run it again.",
2402
+ "The platform takes at most 64 words and 1 MiB, at most 131,071 bytes a word, a program with no leading or trailing whitespace, and no NUL byte. An argument may be empty, start with a newline or span lines. A command it would refuse is refused before anything is created.",
1984
2403
  "With --detach, mend run returns once the command runs. With --json, stdout carries one JSON object in place of the output: the session id, the process id, the worktree, the branch, and the process's status and exit code as last observed, which is how it ended without --detach (and with it, when the command ended first).",
1985
2404
  "Workspaces set PAGER=cat, so git log and friends print instead of waiting for a pager the image does not have. A project variable of the same name, or the pager in your own git config, wins."
1986
2405
  ],
@@ -2005,31 +2424,39 @@ var COMMANDS = [
2005
2424
  name: "logs",
2006
2425
  section: "sessions",
2007
2426
  summary: "print a session's recorded terminal output",
2008
- synopsis: ["[session] [--follow] [--from <sequence>] [--process <id>]"],
2427
+ synopsis: [
2428
+ "[session] [--follow] [--from <sequence>] [--process <id>]",
2429
+ "[session] --service <name-or-id> [--follow] [--from <sequence>]"
2430
+ ],
2009
2431
  description: [
2010
2432
  "Prints what the session's command (or agent) wrote to its terminal, as the record holds it, on stdout. Settled sessions count: the record outlives the process and the workspace. --follow keeps printing until the process ends. The record is printed as it is, nothing taken out: anyone who can read the project can read it, so what a command printed, a password included, reaches them too. A signal stops it and puts the terminal back, exiting 128 + its number: 130 for SIGINT (Ctrl+C), 129 for SIGHUP, 143 for SIGTERM.",
2011
- "<session> is the session id, a prefix of it, or the worktree's name. With none, the one live session is taken. --process reads another process of the session, a shell or a Service attempt, by a prefix of its id."
2433
+ "<session> is the session id, a prefix of it, or the worktree's name. With none, the one live session is taken. --process reads another process of the session, a shell or a Service attempt, by a prefix of its id; a Service's id or name there reads the Service's current attempt.",
2434
+ "--service reads a Service's current attempt, by the Service's id, its name or a prefix of its id, the ids mend service list prints. A full id names its Service before any name does. A name two Services carry is refused, with both ids listed; name one by its id, or name the session. A Service with no attempt (an adopted port, which Mend runs no process for) has nothing recorded, and mend logs says so and exits 1."
2012
2435
  ],
2013
2436
  options: [
2014
2437
  { flag: "--follow, -f", text: "keep printing until the process ends" },
2015
2438
  { flag: "--from <sequence>", text: "start at a record sequence. Default: 0" },
2016
- { flag: "--process <id>", text: "another process of the session, by a prefix of its id" }
2439
+ { flag: "--process <id>", text: "another process of the session, by a prefix of its id" },
2440
+ { flag: "--service <name-or-id>", text: "a Service's current attempt" }
2441
+ ],
2442
+ examples: [
2443
+ { command: "mend logs 3f2a --follow", text: "" },
2444
+ { command: "mend logs --service web --follow", text: "the Service web's output, live" }
2017
2445
  ],
2018
- examples: [{ command: "mend logs 3f2a --follow", text: "" }],
2019
2446
  see: ["run", "wait", "service logs"]
2020
2447
  },
2021
2448
  {
2022
2449
  name: "wait",
2023
2450
  section: "sessions",
2024
2451
  summary: "wait for a session's command to end",
2025
- synopsis: ["[session] [--timeout <seconds>] [--process <id>] [--json]"],
2452
+ synopsis: ["[session] [--timeout <duration>] [--process <id>] [--json]"],
2026
2453
  description: [
2027
2454
  "Returns once the session's command (or agent) has ended, with its exit code: the code the platform reported, or 1 when it reported none. A command that already ended answers at once. While a launch or a resume is starting, the previous process's end does not count. --process waits for one process by its id, the processId mend run --json prints.",
2028
- "When --timeout passes first, mend wait exits 124, as timeout(1) does, and the command keeps running. Stopped by a signal, it exits 128 + its number (130 for Ctrl+C), and the command keeps running. The timeout covers everything: finding the session, every read and every retry. --json then prints the last state read, and nothing more is asked of the server.",
2455
+ "A session that is still starting (its workspace building, its image pulling) is waited through: only its end counts. When --timeout passes first, mend wait exits 124, as timeout(1) does, and the command keeps running. A duration is seconds (90, 90s or .5), minutes (5m) or hours (1h); with none, mend wait waits as long as the command runs. Stopped by a signal, it exits 128 + its number (130 for Ctrl+C), and the command keeps running. The timeout covers everything: finding the session, every read and every retry. --json then prints the last state read, and nothing more is asked of the server.",
2029
2456
  "<session> is the session id, a prefix of it, or the worktree's name. With none, the one live session is taken."
2030
2457
  ],
2031
2458
  options: [
2032
- { flag: "--timeout <seconds>", text: "give up after this long; exit 124" },
2459
+ { flag: "--timeout <duration>", text: "give up after this long (90, 90s, 5m, 1h); exit 124" },
2033
2460
  {
2034
2461
  flag: "--process <id>",
2035
2462
  text: "wait for this process of the session, by a prefix of its id"
@@ -2230,18 +2657,22 @@ var COMMANDS = [
2230
2657
  section: "services",
2231
2658
  summary: "start and supervise a server in the session's workspace",
2232
2659
  synopsis: [
2233
- "[session] --port <port> [--name <n>] [--udp] [--http|--https] [--wait] [--no-connect] -- <command...>",
2234
- "[session] <name> [--wait] [--no-connect]"
2660
+ "[session] --port <port> [--name <n>] [--udp] [--http|--https] [--wait [--timeout <duration>]] [--no-connect] -- <command...>",
2661
+ "[session] <name> [--wait [--timeout <duration>]] [--no-connect]"
2235
2662
  ],
2236
2663
  description: [
2237
2664
  "With --, the command after it is started in the workspace and supervised: its output is recorded, and mend service restart re-runs it. Without --, the name is a Service declared in the worktree's mend.toml. mend service <name> is the shorthand for that.",
2238
- "Mend waits up to a minute for the port to answer before it returns. --wait makes the exit status say how that ended: 0 once the port answered, 1 when it did not, and 124 when the server gave no answer within 90 seconds. The Service keeps running in every case. A waited start returns and opens no tunnel; mend service connect reaches the port. UDP has no probe, and a recipe that declares only a port is adopted with one probe, so --wait takes neither.",
2665
+ "Mend waits up to a minute for the port to answer before it returns. --wait keeps waiting while the Service is still starting (its process runs and its port has not answered yet: building, installing, booting), however long that takes, up to --timeout (default 10 minutes). Mend probes a started Service's port every 20 seconds, so the wait ends at the first probe that answers. The wait judges the one process this start began, by an id the start sends and the server stamps on it, whatever another client starts, restarts or stops meanwhile. The exit status says how it ended: 0 the port answered for that process; 1 Mend refused the start or could not be asked; 2 the process ended before its port answered (the line says its status and exit code; a process its workspace took reads exited, no exit code reported); 3 the server no longer has the session, its workspace gone with it; 124 the Service was still starting when the timeout passed, and it keeps starting. A server older than this CLI stamps no id: a start whose answer an edge cut is then not followed (1), and a command that exits inside the minute reads as the refusal it answers with (1). A waited start returns and opens no tunnel; mend service connect reaches the port. UDP has no probe, and a recipe that declares only a port is adopted with one probe, so --wait takes neither.",
2239
2666
  "The port is tunnelled to this machine's loopback as soon as it listens, unless --no-connect."
2240
2667
  ],
2241
2668
  options: [
2242
2669
  {
2243
2670
  flag: "--wait",
2244
- text: "exit 1 when the port did not answer, 124 with no answer in 90 s; no tunnel"
2671
+ text: "return once the port answers: 0; process ended 2, session gone 3; no tunnel"
2672
+ },
2673
+ {
2674
+ flag: "--timeout <duration>",
2675
+ text: "bound --wait (90, 90s, 5m, 1h); still starting then exits 124. Default: 10m"
2245
2676
  },
2246
2677
  { flag: "--port <port>", text: "the port the command listens on inside the workspace" },
2247
2678
  { flag: "--name <n>", text: "the service's name. Default: the command" },
@@ -2251,9 +2682,13 @@ var COMMANDS = [
2251
2682
  ],
2252
2683
  examples: [
2253
2684
  { command: "mend service run --port 3000 -- pnpm dev", text: "" },
2254
- { command: "mend service web", text: "the Service named web in mend.toml" }
2685
+ { command: "mend service web", text: "the Service named web in mend.toml" },
2686
+ {
2687
+ command: "mend service run web --wait --timeout 15m",
2688
+ text: "returns once web answers; 124 if it is still starting after 15 minutes"
2689
+ }
2255
2690
  ],
2256
- see: ["service init", "service connect", "service logs"]
2691
+ see: ["service init", "service connect", "service logs", "logs"]
2257
2692
  },
2258
2693
  {
2259
2694
  name: "service add",
@@ -2286,9 +2721,13 @@ var COMMANDS = [
2286
2721
  name: "service list",
2287
2722
  section: "services",
2288
2723
  summary: "every live service and its observed state",
2289
- synopsis: [],
2290
- description: ["Name, session, port, whether it listens, and where it is reachable from here."],
2291
- see: ["service run", "service connect"]
2724
+ synopsis: ["[--json]"],
2725
+ description: [
2726
+ "Name, observed state, port, the Service's id and its current attempt's process id, and where it is reachable from here. The process id is what mend logs --process and mend wait --process take; mend logs --service takes the Service's name or id. An adopted port has no process.",
2727
+ "With --json, stdout carries one JSON object: version 1, and services, each with id, name, sessionId, processId (null for an adopted port), status as last observed, workspacePort, protocol (tcp or udp), and hostPort, authority and browserUrl (null when not bound)."
2728
+ ],
2729
+ options: [{ flag: "--json", text: "print the Services as JSON" }],
2730
+ see: ["service run", "service connect", "logs"]
2292
2731
  },
2293
2732
  {
2294
2733
  name: "service logs",
@@ -2807,13 +3246,14 @@ var COMMANDS = [
2807
3246
  section: "this machine",
2808
3247
  summary: "install or repair the local Mend server",
2809
3248
  synopsis: [
2810
- "[--context <name>] [--version <version|latest>] [--bind <ip>] [--ssh-bind <ip>] [--url <origin>] [--origin <origin>...] [--port <n>] [--ssh-port <n>] [--edge <host> | --no-edge] [--exposure <loopback|private|public>] [--tenancy <single|multi>] [--declare <item>...] [--docker-socket <path>] [--assets-dir <dir>] [--offline]"
3249
+ "[--context <name>] [--version <version|latest>] [--bind <ip>] [--ssh-bind <ip>] [--url <origin>] [--origin <origin>...] [--port <n>] [--ssh-port <n>] [--edge <host> | --no-edge] [--exposure <loopback|private|public>] [--tenancy <single|multi>] [--declare <item>...] [--npm-mirror | --no-npm-mirror] [--npm-mirror-max-size <size>] [--docker-mirror | --no-docker-mirror] [--docker-mirror-max-size <size>] [--docker-hub-username <name> --docker-hub-token-stdin --docker-hub-public-only | --no-docker-hub-login] [--docker-socket <path>] [--assets-dir <dir>] [--offline]"
2811
3250
  ],
2812
3251
  description: [
2813
3252
  "Checks a local Unix-socket Docker context and the Compose plugin, downloads the compose and Postgres initialization assets for one Mend release, preserves existing data and secrets, and starts the server. Re-running repairs the same pinned version. A changed --version is refused; use mend server upgrade. Updating this CLI never updates an existing server pin.",
2814
3253
  "The default listens only on localhost at http://localhost:3105. Non-local access requires both --bind and --url. Every extra browser origin must be named with --origin; setup never guesses from the request Host header or network interfaces.",
2815
3254
  "--edge <host> runs a TLS edge in front of Mend: Caddy on ports 80 and 443 of every interface, which obtains and renews a certificate for the host and proxies to Mend's web tier. Mend's own port stays on loopback and the browser origin is https://<host>. The edge's compose overlay and Caddyfile are written into the generation beside compose.yaml, so start, restart and upgrade run them every time. --no-edge takes it away again, and the edge's container with it. A fresh install cannot start with the edge: until the first account exists, registration is open to whoever reaches the origin first, so set up on localhost, create the account, then add the edge.",
2816
3255
  "--exposure declares how the instance is reached, and --tenancy whether one organization or many use it. Both are written into the generation and kept across reruns and upgrades. public needs the edge and an existing first account. With multi, or with public, the multi mode gate's settings follow: MEND_SOURCE_POLICY=tenant and MEND_CAPTURE_REQUIRE_SIZES=true, and public sets MEND_URL_BEARERS=refuse. The server still decides whether it starts, and mend server status shows what it reports.",
3256
+ "Two mirrors run beside Mend unless turned off: an npm mirror (nginx caching npmjs.org, capped at 10g by default, least recently used out) and a Docker mirror (a pull-through cache of Docker Hub, capped at 20g by default: over the cap its cache is cleared). Both leave 5g free on their disk: below that, nginx evicts and the Docker mirror pauses, and sessions pull from Docker Hub directly. Neither publishes a host port. Sessions install npm packages and pull Docker Hub images through them; a project's own npm settings win, and a mirror that is down sends sessions upstream instead. Both are kept across reruns and upgrades, and an upgrade adds them to an install from before them. The Docker mirror pulls anonymously unless given a Docker Hub login: --docker-hub-username with the access token piped on standard input, kept in server.env only. The mirror has no login of its own, so every session that reaches it can pull whatever that token can read: setup takes a login only with --docker-hub-public-only, your statement that the token's access permission is Public Repo Read-only. Mend cannot check a token's scope.",
2817
3257
  "Docker Desktop on Linux and macOS, and OrbStack on macOS, expose client-side proxy sockets. Containers use the daemon-side /var/run/docker.sock. --docker-socket overrides detection and is retained on reruns.",
2818
3258
  "Setup holds an exclusive process lock through startup and health checks. A busy lock reports its owner and manual recovery steps. Never remove a live lock. Private configuration uses immutable generations and an atomic active pointer; failed attempts retain credentials and never delete Docker volumes."
2819
3259
  ],
@@ -2858,6 +3298,35 @@ var COMMANDS = [
2858
3298
  flag: "--declare <item>",
2859
3299
  text: "state a gate item you verified from outside: core-private, edge-tls or workspace-ssh; repeat for more, none clears. Kept across reruns. public with --ssh-bind beyond loopback needs workspace-ssh"
2860
3300
  },
3301
+ {
3302
+ flag: "--npm-mirror, --no-npm-mirror",
3303
+ text: "run or stop the npm mirror; kept across reruns and upgrades. Default: on"
3304
+ },
3305
+ {
3306
+ flag: "--npm-mirror-max-size <size>",
3307
+ text: "the npm mirror's disk cap, such as 20g or 1536m, at least 1g. Default: 10g"
3308
+ },
3309
+ {
3310
+ flag: "--docker-mirror, --no-docker-mirror",
3311
+ text: "run or stop the Docker Hub mirror; kept across reruns and upgrades. Default: on"
3312
+ },
3313
+ {
3314
+ flag: "--docker-mirror-max-size <size>",
3315
+ text: "the Docker mirror's cap, such as 40g; over it the cache is cleared and fills again. Default: 20g"
3316
+ },
3317
+ {
3318
+ flag: "--docker-hub-username <name>",
3319
+ text: "the Docker mirror pulls as this Docker Hub account; needs --docker-hub-token-stdin"
3320
+ },
3321
+ {
3322
+ flag: "--docker-hub-token-stdin",
3323
+ text: "read that account's access token from standard input; kept in server.env, never in argv or a session"
3324
+ },
3325
+ {
3326
+ flag: "--docker-hub-public-only",
3327
+ text: "required with a login: you state the token is scoped Public Repo Read-only, since every session can pull what it can read"
3328
+ },
3329
+ { flag: "--no-docker-hub-login", text: "the Docker mirror pulls anonymously again" },
2861
3330
  {
2862
3331
  flag: "--assets-dir <dir>",
2863
3332
  text: "copy compose.v2.yaml and postgres-init.sh from a release directory; fresh setup requires --version"
@@ -2889,6 +3358,10 @@ var COMMANDS = [
2889
3358
  {
2890
3359
  command: "mend server setup --exposure public --tenancy multi",
2891
3360
  text: "on an install with an edge and a first account: declare public, many organizations"
3361
+ },
3362
+ {
3363
+ command: 'printf %s "$DOCKER_HUB_TOKEN" | mend server setup --docker-hub-username mendbot --docker-hub-token-stdin --docker-hub-public-only',
3364
+ text: "the Docker mirror pulls from Docker Hub as mendbot, with a Public Repo Read-only token"
2892
3365
  }
2893
3366
  ],
2894
3367
  see: ["server", "server status", "login", "doctor", "operator exposure"]
@@ -2896,10 +3369,11 @@ var COMMANDS = [
2896
3369
  {
2897
3370
  name: "server status",
2898
3371
  section: "this machine",
2899
- summary: "show the pin, generation, containers, edge and posture",
3372
+ summary: "show the pin, generation, containers, edge, mirrors and posture",
2900
3373
  synopsis: [""],
2901
3374
  description: [
2902
3375
  "Reads the existing installation without changing its files. A running Mend must answer health with the exact pinned version. A stopped server makes no health claim. Never installs a server implicitly.",
3376
+ "Each mirror the install runs follows, as observed: whether its container runs, what its cache holds, and its traffic. For the npm mirror, the tarball requests in its log for the last 24 hours and how many the cache served; for the Docker mirror, layer and manifest requests since it started and how many the cache served.",
2903
3377
  "Then the posture, declared beside observed. Declared is what this install's configuration says: the edge host, MEND_EXPOSURE and MEND_TENANCY, a default named as one. Observed is what was seen: whether the edge's container runs and whether Caddy's data holds a certificate for the host, and what the running server reports in its health, the exposure it runs with and how many public exposure gate items are open, the tenancy and which multi mode gate items are open. When this machine is signed in to the install as the operator, every item of both gates follows with its detail, as mend operator gate and mend operator exposure print them. None of it is a verdict: the report says what was declared and what was observed."
2904
3378
  ],
2905
3379
  see: ["server logs", "server start", "operator gate", "operator exposure"]
@@ -2918,7 +3392,7 @@ var COMMANDS = [
2918
3392
  {
2919
3393
  name: "server stop",
2920
3394
  section: "this machine",
2921
- summary: "stop Mend, Postgres, Garage and the edge without deleting data",
3395
+ summary: "stop the server's containers without deleting data",
2922
3396
  synopsis: [""],
2923
3397
  description: [
2924
3398
  "Stops only the installation's Compose services, the edge among them when one is set. Connections are interrupted. Workspace containers and volumes remain, but active work may lose connectivity and need reconnection. No volume deletion or Docker prune is performed."
@@ -3447,7 +3921,7 @@ var writeBundle = (files, outPath, now) => {
3447
3921
  entries: entries.map((entry) => ({ path: entry.path, bytes: entry.content.length }))
3448
3922
  };
3449
3923
  };
3450
- var formatBytes = (bytes) => {
3924
+ var formatBytes2 = (bytes) => {
3451
3925
  if (bytes < 1024) return `${bytes} B`;
3452
3926
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} kB`;
3453
3927
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
@@ -3456,8 +3930,8 @@ var NOTICE = "Contains logs and configuration; secrets are redacted, but read it
3456
3930
  var bundleReport = (written) => {
3457
3931
  const width = Math.max(...written.entries.map((entry) => entry.path.length), 0);
3458
3932
  return [
3459
- `${written.path} \xB7 ${formatBytes(written.bytes)}`,
3460
- ...written.entries.map((entry) => ` ${entry.path.padEnd(width)} ${formatBytes(entry.bytes)}`),
3933
+ `${written.path} \xB7 ${formatBytes2(written.bytes)}`,
3934
+ ...written.entries.map((entry) => ` ${entry.path.padEnd(width)} ${formatBytes2(entry.bytes)}`),
3461
3935
  NOTICE
3462
3936
  ];
3463
3937
  };
@@ -4602,7 +5076,7 @@ var fetchedLines = (fetched) => {
4602
5076
  lines.push(`${dim(" switch to it")} git switch ${fetched.branch}`);
4603
5077
  return lines;
4604
5078
  };
4605
- var formatBytes2 = (bytes) => {
5079
+ var formatBytes3 = (bytes) => {
4606
5080
  if (bytes < 1024) return `${bytes} bytes`;
4607
5081
  const units = ["KiB", "MiB", "GiB"];
4608
5082
  let value = bytes / 1024;
@@ -4635,7 +5109,7 @@ var bundleRefusal = (downloaded) => {
4635
5109
  const size = numberField(body, "size");
4636
5110
  const limit = numberField(body, "limit");
4637
5111
  if (downloaded.status === 413 && size !== null && limit !== null) {
4638
- return `bundle not sent \xB7 ${formatBytes2(size)} (${size} bytes) \xB7 the server's limit is ${formatBytes2(limit)} (MEND_BUDGET_BUNDLE_BYTES) \xB7 nothing was fetched`;
5112
+ return `bundle not sent \xB7 ${formatBytes3(size)} (${size} bytes) \xB7 the server's limit is ${formatBytes3(limit)} (MEND_BUDGET_BUNDLE_BYTES) \xB7 nothing was fetched`;
4639
5113
  }
4640
5114
  return stringField2(body, "message") ?? `the bundle request answered ${downloaded.status}`;
4641
5115
  };
@@ -5667,21 +6141,39 @@ var piProfileLines = (scan) => {
5667
6141
 
5668
6142
  // src/run-scripts.ts
5669
6143
  var RUN_ARGV_MAX = 64;
6144
+ var RUN_ARGV_MAX_WORD_BYTES = 128 * 1024 - 1;
6145
+ var RUN_ARGV_MAX_TOTAL_BYTES = 1024 * 1024;
5670
6146
  var whitespaceName = (character) => character === "\n" || character === "\r" ? "a newline" : character === " " ? "a tab" : character === " " ? "a space" : "whitespace";
6147
+ var LONE_SURROGATE = /[\uD800-\uDFFF]/u;
6148
+ var utf8Bytes = (value) => Buffer.byteLength(value, "utf8");
5671
6149
  var runArgvIssue = (argv) => {
5672
6150
  if (argv.length > RUN_ARGV_MAX) {
5673
6151
  return `the command has ${argv.length} words and the platform takes at most ${RUN_ARGV_MAX} \xB7 put them in a script and run that`;
5674
6152
  }
6153
+ const program = argv[0] ?? "";
6154
+ if (program === "") return "the program is empty";
6155
+ if (program.trim() !== program) {
6156
+ const leading = program.trimStart() !== program;
6157
+ const edge = leading ? program.charAt(0) : program.charAt(program.length - 1);
6158
+ return `the program ${leading ? "starts" : "ends"} with ${whitespaceName(edge)} \xB7 trim it and run again`;
6159
+ }
6160
+ let total = 0;
5675
6161
  for (const [index, word] of argv.entries()) {
5676
6162
  const which = index === 0 ? "the program" : `argument ${index}`;
5677
- if (word === "") {
5678
- return `${which} is empty \xB7 the platform refuses empty arguments`;
6163
+ if (word.includes("\0")) {
6164
+ return `${which} contains a NUL byte, which no process argument can carry`;
6165
+ }
6166
+ if (LONE_SURROGATE.test(word)) {
6167
+ return `${which} is not well-formed Unicode (a lone surrogate)`;
5679
6168
  }
5680
- if (word.trim() !== word) {
5681
- const leading = word.trimStart() !== word;
5682
- const edge = leading ? word.charAt(0) : word.charAt(word.length - 1);
5683
- return `${which} ${leading ? "starts" : "ends"} with ${whitespaceName(edge)} \xB7 the platform refuses arguments with leading or trailing whitespace \xB7 trim it and run again`;
6169
+ const bytes = utf8Bytes(word);
6170
+ if (bytes > RUN_ARGV_MAX_WORD_BYTES) {
6171
+ return `${which} is ${bytes} bytes and the platform takes at most ${RUN_ARGV_MAX_WORD_BYTES} a word \xB7 put it in a script and run that`;
5684
6172
  }
6173
+ total += bytes;
6174
+ }
6175
+ if (total > RUN_ARGV_MAX_TOTAL_BYTES) {
6176
+ return `the command is ${total} bytes and the platform takes at most ${RUN_ARGV_MAX_TOTAL_BYTES} \xB7 put it in a script and run that`;
5685
6177
  }
5686
6178
  return null;
5687
6179
  };
@@ -5834,16 +6326,22 @@ var splitArgs = (args, valued, switches) => {
5834
6326
  return { values, on, session };
5835
6327
  };
5836
6328
  var parseLogsArgs = (args) => {
5837
- const split = splitArgs(args, ["--from", "--process"], ["--follow", "-f"]);
6329
+ const split = splitArgs(args, ["--from", "--process", "--service"], ["--follow", "-f"]);
5838
6330
  if ("error" in split) return split;
5839
6331
  const from = split.values.get("--from") ?? "0";
5840
6332
  if (!/^(0|[1-9]\d*)$/u.test(from)) return { error: "--from takes a record sequence, e.g. 0" };
6333
+ const process2 = split.values.get("--process") ?? null;
6334
+ const service = split.values.get("--service") ?? null;
6335
+ if (process2 !== null && service !== null) {
6336
+ return { error: "--process and --service each name one process; pass one of them" };
6337
+ }
5841
6338
  return {
5842
6339
  args: {
5843
6340
  session: split.session,
5844
6341
  follow: split.on.has("--follow") || split.on.has("-f"),
5845
6342
  from,
5846
- process: split.values.get("--process") ?? null
6343
+ process: process2,
6344
+ service
5847
6345
  }
5848
6346
  };
5849
6347
  };
@@ -5851,21 +6349,149 @@ var parseWaitArgs = (args) => {
5851
6349
  const split = splitArgs(args, ["--timeout", "--process"], ["--json"]);
5852
6350
  if ("error" in split) return split;
5853
6351
  const timeout = split.values.get("--timeout");
5854
- const seconds2 = timeout === void 0 ? null : Number(timeout);
5855
- if (seconds2 !== null && !(Number.isFinite(seconds2) && seconds2 > 0)) {
5856
- return { error: "--timeout takes a number of seconds above 0" };
5857
- }
6352
+ const timeoutMs = timeout === void 0 ? null : parseDuration(timeout);
6353
+ if (timeoutMs === null && timeout !== void 0) return { error: DURATION_ISSUE };
5858
6354
  return {
5859
6355
  args: {
5860
6356
  session: split.session,
5861
6357
  process: split.values.get("--process") ?? null,
5862
- timeoutMs: seconds2 === null ? null : Math.ceil(seconds2 * 1e3),
6358
+ timeoutMs,
5863
6359
  json: split.on.has("--json")
5864
6360
  }
5865
6361
  };
5866
6362
  };
6363
+ var DURATION_UNIT_MS = { s: 1e3, m: 6e4, h: 36e5 };
6364
+ var parseDuration = (text) => {
6365
+ const trimmed = text.trim();
6366
+ const unit = DURATION_UNIT_MS[trimmed.slice(-1)];
6367
+ const amount = unit === void 0 ? trimmed === "" ? Number.NaN : Number(trimmed) : /^(?:\d+\.?\d*|\.\d+)$/u.test(trimmed.slice(0, -1)) ? Number(trimmed.slice(0, -1)) : Number.NaN;
6368
+ const ms = Math.ceil(amount * (unit ?? 1e3));
6369
+ return Number.isFinite(ms) && ms > 0 ? ms : null;
6370
+ };
6371
+ var DURATION_ISSUE = "--timeout takes a duration above 0: 90 or 90s, 5m, 1h";
6372
+ var durationLine = (ms) => {
6373
+ const seconds2 = Math.round(ms / 1e3);
6374
+ if (seconds2 < 60) return `${seconds2} s`;
6375
+ const minutes = Math.floor(seconds2 / 60);
6376
+ const rest = seconds2 % 60;
6377
+ if (minutes < 60) return rest === 0 ? `${minutes} min` : `${minutes} min ${rest} s`;
6378
+ const hours = Math.floor(minutes / 60);
6379
+ return minutes % 60 === 0 ? `${hours} h` : `${hours} h ${minutes % 60} min`;
6380
+ };
5867
6381
  var WAIT_TIMED_OUT = 124;
5868
6382
  var isSessionId = (word) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(word);
6383
+ var SERVICE_PROCESS_ENDED = 2;
6384
+ var SERVICE_WORKSPACE_ENDED = 3;
6385
+ var begunBy = (view, attempt2, startId) => attempt2.launchCorrelationId === serviceStartCorrelation(view.service.id, startId);
6386
+ var findStartAttempt = (services, target) => {
6387
+ for (const view of services) {
6388
+ if (view.service.sessionId !== target.sessionId) continue;
6389
+ const attempt2 = view.attempts.find(
6390
+ (candidate) => target.attemptId !== null ? candidate.id === target.attemptId : begunBy(view, candidate, target.startId)
6391
+ );
6392
+ if (attempt2 !== void 0) return { view, attempt: attempt2 };
6393
+ }
6394
+ return void 0;
6395
+ };
6396
+ var STAMP_KIND = serviceStartCorrelation("", "").split(":")[0] ?? "service-start";
6397
+ var answeredAttemptId = (view, startId) => {
6398
+ const own = view.attempts.find((attempt2) => begunBy(view, attempt2, startId));
6399
+ if (own !== void 0) return own.id;
6400
+ const stamps = view.attempts.some(
6401
+ (attempt2) => attempt2.launchCorrelationId?.startsWith(`${STAMP_KIND}:`) === true
6402
+ );
6403
+ return stamps ? null : view.service.currentAttemptId;
6404
+ };
6405
+ var timeOf = (iso) => {
6406
+ if (iso === void 0) return null;
6407
+ const at = Date.parse(iso);
6408
+ return Number.isFinite(at) ? at : null;
6409
+ };
6410
+ var answeredFor = (view, attempt2) => {
6411
+ const observation = view.latestObservation;
6412
+ if (view.service.currentAttemptId !== attempt2.id || view.currentForward === null) return false;
6413
+ if (observation === null || observation.forwardId !== view.currentForward.id) return false;
6414
+ if (observation.state !== "reachable") return false;
6415
+ const began = timeOf(attempt2.createdAt);
6416
+ const observed = timeOf(observation.lastObservedAt);
6417
+ return began === null || observed === null || observed >= began;
6418
+ };
6419
+ var serviceStartStateOf = (read, target) => {
6420
+ const found = findStartAttempt(read.services, target);
6421
+ if (found !== void 0 && found.attempt.exitedAt !== null) {
6422
+ return {
6423
+ kind: "process-ended",
6424
+ processId: found.attempt.id,
6425
+ status: found.attempt.status,
6426
+ exitCode: found.attempt.exitCode ?? null
6427
+ };
6428
+ }
6429
+ if (read.sessionStatus === null) return { kind: "workspace-ended", sessionStatus: null };
6430
+ if (found === void 0) return { kind: "starting", processId: target.attemptId };
6431
+ return answeredFor(found.view, found.attempt) ? { kind: "answered", processId: found.attempt.id, view: found.view } : { kind: "starting", processId: found.attempt.id };
6432
+ };
6433
+ var waitForServiceStart = async (options) => {
6434
+ const pollMs = options.pollMs ?? 2e3;
6435
+ const discoverWithinMs = options.unreachableAfterMs ?? 6e4;
6436
+ const since = options.now();
6437
+ let target = options.target;
6438
+ let last = null;
6439
+ for (; ; ) {
6440
+ const got = await readThrough(options.read, options, options.deadline);
6441
+ if (!got.done) return { kind: "timeout", last };
6442
+ const state = serviceStartStateOf(got.value, target);
6443
+ if (state.kind !== "starting") return state;
6444
+ last = state;
6445
+ if (state.processId === null) {
6446
+ if (options.now() - since >= discoverWithinMs) return { kind: "no-attempt" };
6447
+ } else if (target.attemptId === null) {
6448
+ target = { ...target, attemptId: state.processId };
6449
+ }
6450
+ options.onStarting?.(state);
6451
+ const left = options.deadline === null ? pollMs : Math.min(pollMs, options.deadline - options.now());
6452
+ if (left <= 0) return { kind: "timeout", last };
6453
+ await options.sleep(left);
6454
+ }
6455
+ };
6456
+ var readWithin = (read, clock2, deadline) => readThrough(read, clock2, deadline);
6457
+ var candidateLine = (view) => `${view.service.id} (session ${view.service.sessionId.slice(0, 8)})`;
6458
+ var pickServiceAttempt = (services, needle, sessionId) => {
6459
+ const inScope = services.filter(
6460
+ (view) => sessionId === null || view.service.sessionId === sessionId
6461
+ );
6462
+ const byId = inScope.filter((view) => view.service.id === needle);
6463
+ const byName = inScope.filter((view) => view.service.name === needle);
6464
+ const matches = byId.length > 0 ? byId : byName.length > 0 ? byName : inScope.filter((view) => view.service.id.startsWith(needle));
6465
+ const [chosen] = matches;
6466
+ if (chosen === void 0) {
6467
+ return {
6468
+ error: `no Service ${sessionId === null ? "" : "of this session "}is named "${needle}" or has an id starting with it`,
6469
+ named: false
6470
+ };
6471
+ }
6472
+ if (matches.length > 1) {
6473
+ return {
6474
+ error: `"${needle}" names ${matches.length} Services \xB7 name one by its id: ${matches.map(candidateLine).join(", ")}`,
6475
+ named: true
6476
+ };
6477
+ }
6478
+ const name = chosen.service.name;
6479
+ const attemptId = chosen.service.currentAttemptId;
6480
+ if (attemptId === null) {
6481
+ return {
6482
+ error: `Service ${name} has no attempt yet: Mend has run no process for it, so nothing is recorded (an adopted port has none; mend service run starts one)`,
6483
+ named: true
6484
+ };
6485
+ }
6486
+ const attempt2 = chosen.attempts.find((candidate) => candidate.id === attemptId);
6487
+ if (attempt2 === void 0 || attempt2.sealantSessionId === null) {
6488
+ return {
6489
+ error: `Service ${name}'s attempt ${attemptId.slice(0, 8)} has not opened its terminal yet, so nothing is recorded \xB7 try again in a moment`,
6490
+ named: true
6491
+ };
6492
+ }
6493
+ return { service: chosen, processId: attemptId };
6494
+ };
5869
6495
 
5870
6496
  // src/secret-files.ts
5871
6497
  import * as path14 from "node:path";
@@ -6171,7 +6797,14 @@ var writeDurable = (file, content, mode = 384) => {
6171
6797
  }
6172
6798
  };
6173
6799
  var fileKeys = ["identity", "config", "env", "compose", "postgresInit"];
6174
- var optionalFileKeys = ["posture", "edge", "caddyfile"];
6800
+ var optionalFileKeys = [
6801
+ "posture",
6802
+ "edge",
6803
+ "caddyfile",
6804
+ "mirrors",
6805
+ "npmMirrorConf",
6806
+ "dockerMirrorGuard"
6807
+ ];
6175
6808
  var fileNames = {
6176
6809
  identity: "identity.env",
6177
6810
  config: "server.json",
@@ -6180,7 +6813,10 @@ var fileNames = {
6180
6813
  postgresInit: "postgres-init.sh",
6181
6814
  posture: "compose.posture.yaml",
6182
6815
  edge: "compose.edge.yaml",
6183
- caddyfile: "Caddyfile"
6816
+ caddyfile: "Caddyfile",
6817
+ mirrors: "compose.mirrors.yaml",
6818
+ npmMirrorConf: "npm-mirror.conf",
6819
+ dockerMirrorGuard: "docker-mirror-guard.sh"
6184
6820
  };
6185
6821
  var fileModes = {
6186
6822
  identity: 384,
@@ -6190,7 +6826,10 @@ var fileModes = {
6190
6826
  postgresInit: 493,
6191
6827
  posture: 384,
6192
6828
  edge: 384,
6193
- caddyfile: 420
6829
+ caddyfile: 420,
6830
+ mirrors: 384,
6831
+ npmMirrorConf: 420,
6832
+ dockerMirrorGuard: 420
6194
6833
  };
6195
6834
  var allFiles = (files) => [...fileKeys, ...optionalFileKeys].map((key) => [key, files[key]]);
6196
6835
  var parseLockOwner = (raw) => {
@@ -6294,6 +6933,9 @@ var readActive = (paths) => {
6294
6933
  const posture = optional("posture");
6295
6934
  const edge = optional("edge");
6296
6935
  const caddyfile = optional("caddyfile");
6936
+ const mirrors = optional("mirrors");
6937
+ const npmMirrorConf = optional("npmMirrorConf");
6938
+ const dockerMirrorGuard = optional("dockerMirrorGuard");
6297
6939
  const files = {
6298
6940
  identity: fs14.readFileSync(path15.join(directory, fileNames.identity), "utf8"),
6299
6941
  config: fs14.readFileSync(path15.join(directory, fileNames.config), "utf8"),
@@ -6302,7 +6944,10 @@ var readActive = (paths) => {
6302
6944
  postgresInit: fs14.readFileSync(path15.join(directory, fileNames.postgresInit), "utf8"),
6303
6945
  ...posture === void 0 ? {} : { posture },
6304
6946
  ...edge === void 0 ? {} : { edge },
6305
- ...caddyfile === void 0 ? {} : { caddyfile }
6947
+ ...caddyfile === void 0 ? {} : { caddyfile },
6948
+ ...mirrors === void 0 ? {} : { mirrors },
6949
+ ...npmMirrorConf === void 0 ? {} : { npmMirrorConf },
6950
+ ...dockerMirrorGuard === void 0 ? {} : { dockerMirrorGuard }
6306
6951
  };
6307
6952
  if (readIdentity(paths) !== files.identity) {
6308
6953
  throw new ServerStoreError(
@@ -6852,7 +7497,28 @@ var SETUP_FLAGS = /* @__PURE__ */ new Set([
6852
7497
  "--no-edge",
6853
7498
  "--exposure",
6854
7499
  "--tenancy",
6855
- "--declare"
7500
+ "--declare",
7501
+ "--npm-mirror",
7502
+ "--no-npm-mirror",
7503
+ "--npm-mirror-max-size",
7504
+ "--docker-mirror",
7505
+ "--no-docker-mirror",
7506
+ "--docker-mirror-max-size",
7507
+ "--docker-hub-username",
7508
+ "--docker-hub-token-stdin",
7509
+ "--docker-hub-public-only",
7510
+ "--no-docker-hub-login"
7511
+ ]);
7512
+ var SWITCHES = /* @__PURE__ */ new Set([
7513
+ "--offline",
7514
+ "--no-edge",
7515
+ "--npm-mirror",
7516
+ "--no-npm-mirror",
7517
+ "--docker-mirror",
7518
+ "--no-docker-mirror",
7519
+ "--docker-hub-token-stdin",
7520
+ "--docker-hub-public-only",
7521
+ "--no-docker-hub-login"
6856
7522
  ]);
6857
7523
  var parseExposure = (value) => {
6858
7524
  if (!isExposure(value))
@@ -6895,7 +7561,7 @@ var parseSetupOptions = (args) => {
6895
7561
  if (flag === void 0) continue;
6896
7562
  if (!flag.startsWith("--")) throw setupError(`Unexpected server setup argument "${flag}".`);
6897
7563
  if (!SETUP_FLAGS.has(flag)) throw setupError(`Unknown server setup option "${flag}".`);
6898
- if (flag === "--offline" || flag === "--no-edge") {
7564
+ if (SWITCHES.has(flag)) {
6899
7565
  if (values.has(flag)) throw setupError(`${flag} may be supplied only once.`);
6900
7566
  values.set(flag, ["true"]);
6901
7567
  continue;
@@ -6920,6 +7586,51 @@ var parseSetupOptions = (args) => {
6920
7586
  const declared = values.get("--declare");
6921
7587
  if (edge !== void 0 && values.has("--no-edge"))
6922
7588
  throw setupError("--edge and --no-edge contradict each other.");
7589
+ const pair = (on, off) => {
7590
+ if (values.has(on) && values.has(off))
7591
+ throw setupError(`${on} and ${off} contradict each other.`);
7592
+ return values.has(on) ? true : values.has(off) ? false : void 0;
7593
+ };
7594
+ const npmMirror = pair("--npm-mirror", "--no-npm-mirror");
7595
+ const dockerMirror = pair("--docker-mirror", "--no-docker-mirror");
7596
+ const maxSize = flagValue3("--npm-mirror-max-size");
7597
+ const npmMirrorMaxSize = maxSize === void 0 ? void 0 : parseMirrorSize(maxSize);
7598
+ if (npmMirrorMaxSize === null)
7599
+ throw setupError(
7600
+ `--npm-mirror-max-size must be a whole number of gibibytes or mebibytes, at least 1g, such as 20g or 1536m, not "${maxSize ?? ""}".`
7601
+ );
7602
+ if (npmMirrorMaxSize !== void 0 && npmMirror === false)
7603
+ throw setupError("--npm-mirror-max-size and --no-npm-mirror contradict each other.");
7604
+ const dockerMaxSize = flagValue3("--docker-mirror-max-size");
7605
+ const dockerMirrorMaxSize = dockerMaxSize === void 0 ? void 0 : parseMirrorSize(dockerMaxSize);
7606
+ if (dockerMirrorMaxSize === null)
7607
+ throw setupError(
7608
+ `--docker-mirror-max-size must be a whole number of gibibytes or mebibytes, at least 1g, such as 40g, not "${dockerMaxSize ?? ""}".`
7609
+ );
7610
+ if (dockerMirrorMaxSize !== void 0 && dockerMirror === false)
7611
+ throw setupError("--docker-mirror-max-size and --no-docker-mirror contradict each other.");
7612
+ const dockerHubUsername = flagValue3("--docker-hub-username");
7613
+ const dockerHubTokenStdin = values.has("--docker-hub-token-stdin");
7614
+ const noDockerHubLogin = values.has("--no-docker-hub-login");
7615
+ if (dockerHubUsername === void 0 !== !dockerHubTokenStdin)
7616
+ throw setupError(
7617
+ "--docker-hub-username and --docker-hub-token-stdin go together: the user name, and the access token on standard input."
7618
+ );
7619
+ if (dockerHubUsername !== void 0 && !isDockerHubCredential(dockerHubUsername))
7620
+ throw setupError(`--docker-hub-username "${dockerHubUsername}" is not a Docker Hub user name.`);
7621
+ const dockerHubPublicOnly = values.has("--docker-hub-public-only");
7622
+ if (dockerHubPublicOnly && dockerHubUsername === void 0)
7623
+ throw setupError(
7624
+ "--docker-hub-public-only goes with --docker-hub-username and --docker-hub-token-stdin: it states what the token you give can read."
7625
+ );
7626
+ if (dockerHubUsername !== void 0 && !dockerHubPublicOnly)
7627
+ throw setupError(
7628
+ `A Docker Hub login needs --docker-hub-public-only. The Docker mirror has no login of its own: every session that reaches it can pull whatever the token can read, private repositories included. Create a Docker Hub personal access token with the access permission "Public Repo Read-only", pipe it on standard input, and add --docker-hub-public-only to state that it is one. Mend cannot check a token's scope.`
7629
+ );
7630
+ if (dockerHubUsername !== void 0 && (noDockerHubLogin || dockerMirror === false))
7631
+ throw setupError(
7632
+ `--docker-hub-username and ${noDockerHubLogin ? "--no-docker-hub-login" : "--no-docker-mirror"} contradict each other.`
7633
+ );
6923
7634
  return {
6924
7635
  context: flagValue3("--context"),
6925
7636
  version: version === void 0 ? void 0 : parseVersion(version),
@@ -6936,9 +7647,60 @@ var parseSetupOptions = (args) => {
6936
7647
  noEdge: values.has("--no-edge"),
6937
7648
  exposure: exposure === void 0 ? void 0 : parseExposure(exposure),
6938
7649
  tenancy: tenancy === void 0 ? void 0 : parseTenancy(tenancy),
6939
- declared: declared === void 0 ? void 0 : parseDeclared(declared)
7650
+ declared: declared === void 0 ? void 0 : parseDeclared(declared),
7651
+ npmMirror,
7652
+ npmMirrorMaxSize,
7653
+ dockerMirror,
7654
+ dockerMirrorMaxSize,
7655
+ dockerHubUsername,
7656
+ dockerHubTokenStdin,
7657
+ noDockerHubLogin
6940
7658
  };
6941
7659
  };
7660
+ var resolveMirrors = (saved, options) => {
7661
+ const base = saved ?? DEFAULT_MIRRORS;
7662
+ const npmOn = options.npmMirror ?? (options.npmMirrorMaxSize !== void 0 || base.npm !== null);
7663
+ const dockerOn = options.dockerMirror ?? (options.dockerHubUsername !== void 0 || options.dockerMirrorMaxSize !== void 0 || base.docker !== null);
7664
+ const upstreamUser = options.dockerHubUsername ?? (options.noDockerHubLogin ? void 0 : dockerMirrorLogin(base.docker));
7665
+ const dockerMaxSize = options.dockerMirrorMaxSize ?? base.docker?.maxSize ?? DEFAULT_DOCKER_MIRROR_MAX_SIZE;
7666
+ return {
7667
+ npm: npmOn ? {
7668
+ maxSize: options.npmMirrorMaxSize ?? base.npm?.maxSize ?? DEFAULT_NPM_MIRROR_MAX_SIZE
7669
+ } : null,
7670
+ docker: !dockerOn ? null : upstreamUser === void 0 ? { maxSize: dockerMaxSize } : { maxSize: dockerMaxSize, upstreamUser, upstreamPublicOnly: true }
7671
+ };
7672
+ };
7673
+ var parseMirrorsField = (value) => {
7674
+ const fields = ownFields(value);
7675
+ const corrupt = () => setupError(
7676
+ "Server config is corrupt: mirrors must hold npm and docker, each an object or null."
7677
+ );
7678
+ if (fields === null || !fields.has("npm") || !fields.has("docker")) throw corrupt();
7679
+ const npm = fields.get("npm");
7680
+ const docker2 = fields.get("docker");
7681
+ let parsedNpm = null;
7682
+ if (npm !== null) {
7683
+ const maxSize = ownFields(npm)?.get("maxSize");
7684
+ if (typeof maxSize !== "string" || parseMirrorSize(maxSize) !== maxSize) throw corrupt();
7685
+ parsedNpm = { maxSize };
7686
+ }
7687
+ let parsedDocker = null;
7688
+ if (docker2 !== null) {
7689
+ const dockerFields = ownFields(docker2);
7690
+ if (dockerFields === null) throw corrupt();
7691
+ const maxSize = dockerFields.get("maxSize");
7692
+ if (typeof maxSize !== "string" || parseMirrorSize(maxSize) !== maxSize) throw corrupt();
7693
+ const user = dockerFields.get("upstreamUser");
7694
+ if (user !== void 0 && (typeof user !== "string" || !isDockerHubCredential(user)))
7695
+ throw corrupt();
7696
+ if (user !== void 0 && dockerFields.get("upstreamPublicOnly") !== true)
7697
+ throw setupError(
7698
+ "Server config is corrupt: the Docker mirror's login lacks upstreamPublicOnly. Run mend server setup with --docker-hub-public-only and a Public Repo Read-only token, or --no-docker-hub-login."
7699
+ );
7700
+ parsedDocker = user === void 0 ? { maxSize } : { maxSize, upstreamUser: user, upstreamPublicOnly: true };
7701
+ }
7702
+ return { npm: parsedNpm, docker: parsedDocker };
7703
+ };
6942
7704
  var parseUrl = (input, label) => {
6943
7705
  try {
6944
7706
  return new URL(input);
@@ -7137,7 +7899,8 @@ var parseServerConfig = (raw) => {
7137
7899
  ...edgeHost === void 0 ? {} : { edgeHost },
7138
7900
  ...exposure === void 0 ? {} : { exposure },
7139
7901
  ...tenancy === void 0 ? {} : { tenancy },
7140
- ...declared.length === 0 ? {} : { declared }
7902
+ ...declared.length === 0 ? {} : { declared },
7903
+ ...fields.has("mirrors") ? { mirrors: parseMirrorsField(fields.get("mirrors")) } : {}
7141
7904
  };
7142
7905
  if (config.schemaVersion !== CONFIG_SCHEMA_VERSION || config.assetContract !== ASSET_CONTRACT && !LEGACY_ASSET_CONTRACTS.has(config.assetContract)) {
7143
7906
  throw setupError(
@@ -7164,7 +7927,14 @@ var parseServerConfig = (raw) => {
7164
7927
  noEdge: false,
7165
7928
  exposure: config.exposure,
7166
7929
  tenancy: config.tenancy,
7167
- declared: config.declared
7930
+ declared: config.declared,
7931
+ npmMirror: void 0,
7932
+ npmMirrorMaxSize: void 0,
7933
+ dockerMirror: void 0,
7934
+ dockerMirrorMaxSize: void 0,
7935
+ dockerHubUsername: void 0,
7936
+ dockerHubTokenStdin: false,
7937
+ noDockerHubLogin: false
7168
7938
  });
7169
7939
  return config;
7170
7940
  };
@@ -7195,8 +7965,12 @@ var parseSecrets = (raw) => {
7195
7965
  betterAuthSecret: envValue(fields, "BETTER_AUTH_SECRET"),
7196
7966
  sealantCredentialsKey: envValue(fields, "SEALANT_CREDENTIALS_KEY"),
7197
7967
  sealantServiceKey: envValue(fields, "SEALANT_SERVICE_KEY"),
7198
- workspaceSshGatewayToken: envValue(fields, "WORKSPACE_SSH_GATEWAY_TOKEN")
7968
+ workspaceSshGatewayToken: envValue(fields, "WORKSPACE_SSH_GATEWAY_TOKEN"),
7969
+ ...fields.has("MEND_DOCKER_HUB_TOKEN") ? { dockerHubToken: envValue(fields, "MEND_DOCKER_HUB_TOKEN") } : {}
7199
7970
  };
7971
+ if (secrets.dockerHubToken !== void 0 && !isDockerHubCredential(secrets.dockerHubToken)) {
7972
+ throw setupError("Server secrets are corrupt: MEND_DOCKER_HUB_TOKEN has an invalid value.");
7973
+ }
7200
7974
  if (!/^slt_svc_[0-9a-f]{64}$/.test(secrets.sealantServiceKey)) {
7201
7975
  throw setupError("Server secrets are corrupt: SEALANT_SERVICE_KEY has an invalid value.");
7202
7976
  }
@@ -7523,6 +8297,9 @@ var renderSecrets = (secrets, config) => {
7523
8297
  "MEND_STORE_VOLUME_NAME=mend-store",
7524
8298
  "MEND_CONTROL_VOLUME_NAME=mend-control",
7525
8299
  ...renderGarage(secrets, config),
8300
+ // The mirrors' size cap and Docker Hub login (server-mirrors.ts); the token reaches only the
8301
+ // Docker mirror's container.
8302
+ ...mirrorsEnvLines(config.mirrors, secrets.dockerHubToken),
7526
8303
  `DOCKER_SOCKET_PATH=${config.dockerSocket}`,
7527
8304
  ""
7528
8305
  ].join("\n");
@@ -7573,6 +8350,47 @@ var validateOverlays = (config, files) => {
7573
8350
  }
7574
8351
  return edgeImageOf(files.edge);
7575
8352
  };
8353
+ var validateMirrors = (config, secrets, files) => {
8354
+ const mirrors = config.mirrors;
8355
+ if (runsMirrors(mirrors) !== (files.mirrors !== void 0)) {
8356
+ throw setupError(
8357
+ `Server generation is corrupt: ${MIRRORS_COMPOSE_FILE} does not match the persisted server config.`
8358
+ );
8359
+ }
8360
+ if ((mirrors !== void 0 && mirrors.npm !== null) !== (files.npmMirrorConf !== void 0)) {
8361
+ throw setupError(
8362
+ `Server generation is corrupt: ${NPM_MIRROR_CONF_NAME} does not match the persisted server config.`
8363
+ );
8364
+ }
8365
+ if (dockerMirrorLogin(mirrors?.docker ?? null) === void 0 !== (secrets.dockerHubToken === void 0)) {
8366
+ throw setupError(
8367
+ "Server secrets are corrupt: a Docker Hub login needs both MEND_DOCKER_HUB_USERNAME and MEND_DOCKER_HUB_TOKEN."
8368
+ );
8369
+ }
8370
+ if (files.mirrors === void 0) return [];
8371
+ for (const service of mirrorServices(mirrors)) {
8372
+ if (!files.mirrors.includes(`
8373
+ ${service}:
8374
+ `))
8375
+ throw setupError(
8376
+ `Server generation is corrupt: ${MIRRORS_COMPOSE_FILE} does not run the ${service} its config declares.`
8377
+ );
8378
+ }
8379
+ if ((mirrors !== void 0 && mirrors.docker !== null) !== (files.dockerMirrorGuard !== void 0)) {
8380
+ throw setupError(
8381
+ `Server generation is corrupt: ${DOCKER_MIRROR_GUARD_NAME} does not match the persisted server config.`
8382
+ );
8383
+ }
8384
+ if (files.dockerMirrorGuard !== void 0 && !files.dockerMirrorGuard.includes("registry serve /etc/distribution/config.yml"))
8385
+ throw setupError(
8386
+ `Server generation is corrupt: ${DOCKER_MIRROR_GUARD_NAME} is not the Docker mirror's guard.`
8387
+ );
8388
+ if (files.npmMirrorConf !== void 0 && !files.npmMirrorConf.includes("proxy_cache npm;"))
8389
+ throw setupError(
8390
+ `Server generation is corrupt: ${NPM_MIRROR_CONF_NAME} is not the npm mirror's configuration.`
8391
+ );
8392
+ return mirrorImagesOf(files.mirrors);
8393
+ };
7576
8394
  var readServerInstallation = (store) => {
7577
8395
  try {
7578
8396
  const generation = storeValue(store.readActive());
@@ -7587,12 +8405,14 @@ var readServerInstallation = (store) => {
7587
8405
  validateComposeAsset(generation.files.compose);
7588
8406
  validatePostgresAsset(generation.files.postgresInit);
7589
8407
  const edgeImage = validateOverlays(config, generation.files);
8408
+ const mirrorImages = validateMirrors(config, secrets, generation.files);
7590
8409
  return {
7591
8410
  _tag: "ok",
7592
8411
  value: {
7593
8412
  config,
7594
8413
  directory: generation.directory,
7595
- ...edgeImage === void 0 ? {} : { edgeImage }
8414
+ ...edgeImage === void 0 ? {} : { edgeImage },
8415
+ ...mirrorImages.length === 0 ? {} : { mirrorImages }
7596
8416
  }
7597
8417
  };
7598
8418
  } catch (cause) {
@@ -7623,7 +8443,10 @@ var readServerInstallationFacts = async (configDir) => {
7623
8443
  overlays: [
7624
8444
  ...generation.files.edge === void 0 ? [] : [{ name: "compose.edge.yaml", content: generation.files.edge }],
7625
8445
  ...generation.files.caddyfile === void 0 ? [] : [{ name: "Caddyfile", content: generation.files.caddyfile }],
7626
- ...generation.files.posture === void 0 ? [] : [{ name: "compose.posture.yaml", content: generation.files.posture }]
8446
+ ...generation.files.posture === void 0 ? [] : [{ name: "compose.posture.yaml", content: generation.files.posture }],
8447
+ ...generation.files.mirrors === void 0 ? [] : [{ name: MIRRORS_COMPOSE_FILE, content: generation.files.mirrors }],
8448
+ ...generation.files.npmMirrorConf === void 0 ? [] : [{ name: NPM_MIRROR_CONF_NAME, content: generation.files.npmMirrorConf }],
8449
+ ...generation.files.dockerMirrorGuard === void 0 ? [] : [{ name: DOCKER_MIRROR_GUARD_NAME, content: generation.files.dockerMirrorGuard }]
7627
8450
  ],
7628
8451
  envKeys: envKeyNames(generation.files.env)
7629
8452
  };
@@ -7635,6 +8458,7 @@ var readServerInstallationFacts = async (configDir) => {
7635
8458
  };
7636
8459
  var generationFiles = (config, secrets, assets, identity = renderIdentity(secrets)) => {
7637
8460
  const posture = renderPostureOverlay(config);
8461
+ const mirrors = renderMirrorsOverlay(config.mirrors);
7638
8462
  return {
7639
8463
  identity,
7640
8464
  config: `${JSON.stringify(config, null, 2)}
@@ -7642,7 +8466,10 @@ var generationFiles = (config, secrets, assets, identity = renderIdentity(secret
7642
8466
  env: renderSecrets(secrets, config),
7643
8467
  ...assets,
7644
8468
  ...posture === void 0 ? {} : { posture },
7645
- ...config.edgeHost === void 0 ? {} : { edge: EDGE_COMPOSE_OVERLAY, caddyfile: EDGE_CADDYFILE }
8469
+ ...config.edgeHost === void 0 ? {} : { edge: EDGE_COMPOSE_OVERLAY, caddyfile: EDGE_CADDYFILE },
8470
+ ...mirrors === void 0 ? {} : { mirrors },
8471
+ ...config.mirrors === void 0 || config.mirrors.npm === null ? {} : { npmMirrorConf: NPM_MIRROR_CONF },
8472
+ ...config.mirrors === void 0 || config.mirrors.docker === null ? {} : { dockerMirrorGuard: DOCKER_MIRROR_GUARD }
7646
8473
  };
7647
8474
  };
7648
8475
  var persistSetup = (store, config, secrets, assets) => storeValue(store.prepare(generationFiles(config, secrets, assets)));
@@ -7811,6 +8638,17 @@ var checkLocalImages = async (runtime, installation, policy = "local") => {
7811
8638
  if (edge.status !== 0)
7812
8639
  throw commandFailure(`Preload ${installation.edgeImage} before continuing`, edge);
7813
8640
  }
8641
+ for (const mirrorImage of installation.mirrorImages ?? []) {
8642
+ const mirror = await inspectImage(
8643
+ runtime,
8644
+ config.dockerContext,
8645
+ mirrorImage,
8646
+ "{{.Id}}",
8647
+ policy
8648
+ );
8649
+ if (mirror.status !== 0)
8650
+ throw commandFailure(`Preload ${mirrorImage} before continuing`, mirror);
8651
+ }
7814
8652
  };
7815
8653
  var checkComposeImages = async (runtime, installation) => {
7816
8654
  const output = await runtime.run(
@@ -7823,11 +8661,12 @@ var checkComposeImages = async (runtime, installation) => {
7823
8661
  `ghcr.io/sealant-sh/mend:${installation.config.serverVersion}`,
7824
8662
  "postgres:17-alpine",
7825
8663
  ...installation.config.bucket === "garage" ? [GARAGE_IMAGE] : [],
7826
- ...installation.edgeImage === void 0 ? [] : [installation.edgeImage]
8664
+ ...installation.edgeImage === void 0 ? [] : [installation.edgeImage],
8665
+ ...installation.mirrorImages ?? []
7827
8666
  ].toSorted();
7828
8667
  if (images.join("\n") !== expected.join("\n")) {
7829
8668
  throw setupError(
7830
- `Compose must use only the canonical pinned Mend image, official postgres:17-alpine${installation.config.bucket === "garage" ? ` and ${GARAGE_IMAGE}` : ""}${installation.edgeImage === void 0 ? "" : ` and the edge's ${installation.edgeImage}`}.`
8669
+ `Compose must use only the canonical pinned Mend image, official postgres:17-alpine${installation.config.bucket === "garage" ? ` and ${GARAGE_IMAGE}` : ""}${installation.edgeImage === void 0 ? "" : ` and the edge's ${installation.edgeImage}`}${installation.mirrorImages === void 0 ? "" : ` and the mirrors' ${installation.mirrorImages.join(", ")}`}.`
7831
8670
  );
7832
8671
  }
7833
8672
  };
@@ -7874,8 +8713,16 @@ var initGarage = async (runtime, installation, secrets) => {
7874
8713
  var startingNotice = (runtime, version) => runtime.writeLine(
7875
8714
  `Starting Mend ${version} containers; Docker waits up to ${serverProcessDeadlines.composeWaitSeconds}s for them to report healthy`
7876
8715
  );
7877
- var removeStrayEdge = async (runtime, installation) => {
7878
- if (installation.config.edgeHost !== void 0) return;
8716
+ var strayServices = (config) => [
8717
+ ...config.edgeHost === void 0 ? [{ service: "edge", label: "edge", holds: "It may still hold 80 and 443." }] : [],
8718
+ ...config.mirrors?.npm === null ? [{ service: "npm-mirror", label: "npm mirror", holds: "It keeps running beside Mend." }] : [],
8719
+ ...config.mirrors?.docker === null ? [{ service: "docker-mirror", label: "Docker mirror", holds: "It keeps running beside Mend." }] : []
8720
+ ];
8721
+ var removeStrayServices = async (runtime, installation) => {
8722
+ for (const stray of strayServices(installation.config))
8723
+ await removeStrayService(runtime, installation, stray);
8724
+ };
8725
+ var removeStrayService = async (runtime, installation, stray) => {
7879
8726
  const context = installation.config.dockerContext;
7880
8727
  const listed = await runtime.run("docker", [
7881
8728
  "--context",
@@ -7886,13 +8733,13 @@ var removeStrayEdge = async (runtime, installation) => {
7886
8733
  "--filter",
7887
8734
  "label=com.docker.compose.project=mend",
7888
8735
  "--filter",
7889
- "label=com.docker.compose.service=edge",
8736
+ `label=com.docker.compose.service=${stray.service}`,
7890
8737
  "--format",
7891
8738
  '{{.Names}} {{.Label "com.docker.compose.project.working_dir"}}'
7892
8739
  ]);
7893
8740
  if (listed.status !== 0 || listed.error !== void 0) {
7894
8741
  runtime.writeLine(
7895
- `Could not list this project's containers: ${outputDetail(listed)}. An edge container left by an earlier generation, if any, stays until the next start. mend server status shows it.`
8742
+ `Could not list this project's containers: ${outputDetail(listed)}. An ${stray.label} container left by an earlier generation, if any, stays until the next start. mend server status shows it.`
7896
8743
  );
7897
8744
  return;
7898
8745
  }
@@ -7908,9 +8755,10 @@ var removeStrayEdge = async (runtime, installation) => {
7908
8755
  const [name = "", workingDir = ""] = line.split(" ");
7909
8756
  return { name, workingDir };
7910
8757
  }).filter((row) => row.name !== "");
8758
+ const label = stray.label.charAt(0).toUpperCase() + stray.label.slice(1);
7911
8759
  for (const row of rows.filter((candidate) => !own(candidate.workingDir))) {
7912
8760
  runtime.writeLine(
7913
- `Edge container ${row.name} was started from ${row.workingDir || "an unknown directory"}, not from this installation's generations. It stays.`
8761
+ `${label} container ${row.name} was started from ${row.workingDir || "an unknown directory"}, not from this installation's generations. It stays.`
7914
8762
  );
7915
8763
  }
7916
8764
  const names = rows.filter((row) => own(row.workingDir)).map((row) => row.name);
@@ -7925,16 +8773,16 @@ var removeStrayEdge = async (runtime, installation) => {
7925
8773
  ]);
7926
8774
  if (removed.status !== 0 || removed.error !== void 0) {
7927
8775
  runtime.writeLine(
7928
- `Could not remove the edge container ${names.join(", ")}: ${outputDetail(removed)}. It may still hold 80 and 443. To remove it: docker --context ${context} container rm --force ${names.join(" ")}`
8776
+ `Could not remove the ${stray.label} container ${names.join(", ")}: ${outputDetail(removed)}. ${stray.holds} To remove it: docker --context ${context} container rm --force ${names.join(" ")}`
7929
8777
  );
7930
8778
  return;
7931
8779
  }
7932
8780
  runtime.writeLine(
7933
- `Removed the edge container ${names.join(", ")}, which this generation does not run.`
8781
+ `Removed the ${stray.label} container ${names.join(", ")}, which this generation does not run.`
7934
8782
  );
7935
8783
  };
7936
8784
  var startCompose = async (runtime, installation) => {
7937
- await removeStrayEdge(runtime, installation);
8785
+ await removeStrayServices(runtime, installation);
7938
8786
  startingNotice(runtime, installation.config.serverVersion);
7939
8787
  const compose = await runtime.run(
7940
8788
  "docker",
@@ -7974,6 +8822,48 @@ var sshPublicationLine = async (runtime, config) => {
7974
8822
  return `Workspace SSH is published on ${published}. From this machine: ${observed}. Who else reaches it is up to the network and its firewall; mend operator exposure reports it as workspace-ssh.`;
7975
8823
  };
7976
8824
  var edgeStartedLine = (config) => config.edgeHost === void 0 ? null : `The edge for ${config.edgeHost} is up on 80 and 443. Caddy asks for its certificate once ${config.edgeHost} resolves to this machine and both ports reach it from the Internet. mend server status says whether it holds one, and mend server logs shows what Caddy tried.${config.sshBind === void 0 ? ` Workspace SSH is published on ${config.bind}:${config.sshPort} only, so Remote-SSH and mend ssh from another machine cannot reach it; --ssh-bind 0.0.0.0 publishes it.` : ` Workspace SSH is published on ${config.sshBind}:${config.sshPort}.`}`;
8825
+ var resolveDockerHubToken = async (runtime, options, mirrors, store) => {
8826
+ if (dockerMirrorLogin(mirrors.docker) === void 0) return void 0;
8827
+ if (options.dockerHubTokenStdin) {
8828
+ if (runtime.readStdin === void 0)
8829
+ throw setupError("--docker-hub-token-stdin needs standard input, and none is available.");
8830
+ const token = (await runtime.readStdin()).trim();
8831
+ if (!isDockerHubCredential(token))
8832
+ throw setupError(
8833
+ "--docker-hub-token-stdin read no Docker Hub access token: pipe the token alone, such as a personal access token (dckr_pat_\u2026)."
8834
+ );
8835
+ return token;
8836
+ }
8837
+ const active = storeValue(store.readActive());
8838
+ return active === null ? void 0 : parseSecrets(active.files.env).dockerHubToken;
8839
+ };
8840
+ var mirrorsChangedLines = (before, config) => {
8841
+ const after = config.mirrors;
8842
+ if (after === void 0) return [];
8843
+ const lines = [];
8844
+ const said = (name, was, is, volume, reach) => {
8845
+ if (is && !was) lines.push(`The ${name} runs on this install. ${reach}`);
8846
+ if (was && !is)
8847
+ lines.push(
8848
+ `The ${name} is off. Its cache stays until you remove it: docker --context ${config.dockerContext} volume rm ${volume}`
8849
+ );
8850
+ };
8851
+ said(
8852
+ "npm mirror",
8853
+ before !== void 0 && before.npm !== null,
8854
+ after.npm !== null,
8855
+ "mend_mend-npm-mirror",
8856
+ `New sessions install npm packages through it, capped at ${after.npm?.maxSize ?? ""}.`
8857
+ );
8858
+ said(
8859
+ "Docker mirror",
8860
+ before !== void 0 && before.docker !== null,
8861
+ after.docker !== null,
8862
+ "mend_mend-docker-mirror",
8863
+ `New sessions' Docker daemons pull Docker Hub images through it (${DOCKER_MIRROR_CONTAINER}).`
8864
+ );
8865
+ return lines;
8866
+ };
7977
8867
  var setupServer = async (args, runtime, store) => {
7978
8868
  if (runtime.platform !== "linux" && runtime.platform !== "darwin") {
7979
8869
  throw setupError(`mend server setup supports Linux and macOS, not ${runtime.platform}.`);
@@ -7999,6 +8889,7 @@ var setupServer = async (args, runtime, store) => {
7999
8889
  const operatingSystem = await checkDocker(runtime, selectedContext.name);
8000
8890
  runtime.writeLine(`Using Docker context "${selectedContext.name}" (${selectedContext.endpoint})`);
8001
8891
  const serverVersion = await resolveServerVersion(runtime, options, existing?.config ?? null);
8892
+ const mirrors = resolveMirrors(existing?.config.mirrors, options);
8002
8893
  const configWithoutBucket = {
8003
8894
  schemaVersion: CONFIG_SCHEMA_VERSION,
8004
8895
  assetContract: ASSET_CONTRACT,
@@ -8009,8 +8900,10 @@ var setupServer = async (args, runtime, store) => {
8009
8900
  ...selectedContext,
8010
8901
  operatingSystem
8011
8902
  }),
8012
- ...validateExposure(existing?.config ?? null, options)
8903
+ ...validateExposure(existing?.config ?? null, options),
8904
+ mirrors
8013
8905
  };
8906
+ const dockerHubToken = await resolveDockerHubToken(runtime, options, mirrors, store);
8014
8907
  const assets = await resolveAssets(runtime, serverVersion, existing, store, options);
8015
8908
  checkSshBindAsset(configWithoutBucket, assets.compose);
8016
8909
  const bucket = composeBucket(assets.compose);
@@ -8036,12 +8929,16 @@ var setupServer = async (args, runtime, store) => {
8036
8929
  `A fresh install cannot start with the edge or as public: until the first account exists, registration is open to whoever reaches the origin first, and the server refuses MEND_EXPOSURE=public without an operator account. Run mend server setup without --edge and --exposure public, create the first account at http://localhost:${config.appPort}, then run mend server setup --edge <host> and declare the posture.`
8037
8930
  );
8038
8931
  }
8039
- const secrets = savedSecrets ?? createSecrets(runtime);
8932
+ const secrets = {
8933
+ ...savedSecrets ?? createSecrets(runtime),
8934
+ ...dockerHubToken === void 0 ? {} : { dockerHubToken }
8935
+ };
8040
8936
  const generation = persistSetup(store, config, secrets, assets);
8041
8937
  const installation = {
8042
8938
  directory: generation.directory,
8043
8939
  config,
8044
- ...generation.files.edge === void 0 ? {} : { edgeImage: edgeImageOf(generation.files.edge) }
8940
+ ...generation.files.edge === void 0 ? {} : { edgeImage: edgeImageOf(generation.files.edge) },
8941
+ ...generation.files.mirrors === void 0 ? {} : { mirrorImages: mirrorImagesOf(generation.files.mirrors) }
8045
8942
  };
8046
8943
  await checkComposeImages(runtime, installation);
8047
8944
  const ownership2 = await claimServerDockerVolumes(runtime, {
@@ -8070,6 +8967,7 @@ var setupServer = async (args, runtime, store) => {
8070
8967
  `The edge for ${existing.config.edgeHost} is gone. Its certificate volumes stay until you remove them: docker --context ${config.dockerContext} volume rm mend_mend-edge-data mend_mend-edge-config`
8071
8968
  );
8072
8969
  }
8970
+ for (const line of mirrorsChangedLines(existing?.config.mirrors, config)) runtime.writeLine(line);
8073
8971
  runtime.writeLine(
8074
8972
  `Open ${config.appUrl}, create the first account, then run: mend login --url ${config.appUrl}`
8075
8973
  );
@@ -8134,7 +9032,7 @@ var interruptionNotice = (runtime) => runtime.writeLine(
8134
9032
  "Connections will be interrupted. Workspace containers and data are retained, but active work can lose connectivity and may need reconnection. Mend does not stop workspace containers."
8135
9033
  );
8136
9034
  var startInstallation = async (runtime, installation, secrets) => {
8137
- await removeStrayEdge(runtime, installation);
9035
+ await removeStrayServices(runtime, installation);
8138
9036
  startingNotice(runtime, installation.config.serverVersion);
8139
9037
  await composeCommand(runtime, installation, [
8140
9038
  "up",
@@ -8182,7 +9080,7 @@ var parseUpgradeOptions = (args) => {
8182
9080
  );
8183
9081
  return { ...options, fromPreview, keepBackups };
8184
9082
  };
8185
- var formatBytes3 = (bytes) => {
9083
+ var formatBytes4 = (bytes) => {
8186
9084
  if (bytes < 1024) return `${bytes} B`;
8187
9085
  const units = ["KiB", "MiB", "GiB", "TiB"];
8188
9086
  let value = bytes / 1024;
@@ -8225,11 +9123,11 @@ var pruneUpgradeBackups = (runtime, store, backup, keep) => {
8225
9123
  const { removed, kept, held, failed, unsynced } = pruned.value;
8226
9124
  for (const entry of removed)
8227
9125
  runtime.writeLine(
8228
- `Removed upgrade backup ${entry.directory} \xB7 ${formatBytes3(entry.bytes)}${entry.interrupted ? " \xB7 finishing a removal a crash cut short" : entry.legacy ? " \xB7 from before 0.36, no recorded outcome" : ""}`
9126
+ `Removed upgrade backup ${entry.directory} \xB7 ${formatBytes4(entry.bytes)}${entry.interrupted ? " \xB7 finishing a removal a crash cut short" : entry.legacy ? " \xB7 from before 0.36, no recorded outcome" : ""}`
8229
9127
  );
8230
9128
  const freed = removed.reduce((total, entry) => total + entry.bytes, 0);
8231
9129
  runtime.writeLine(
8232
- `Upgrade backups \xB7 removed ${removed.length}${removed.length > 0 ? ` \xB7 ${formatBytes3(freed)} freed` : ""} \xB7 kept ${kept.length} (--keep-backups ${keep})`
9130
+ `Upgrade backups \xB7 removed ${removed.length}${removed.length > 0 ? ` \xB7 ${formatBytes4(freed)} freed` : ""} \xB7 kept ${kept.length} (--keep-backups ${keep})`
8233
9131
  );
8234
9132
  for (const entry of failed)
8235
9133
  runtime.writeLine(`Could not remove upgrade backup ${entry.directory}: ${entry.message}`);
@@ -8398,14 +9296,23 @@ var upgradeServer = async (args, runtime, store, existing) => {
8398
9296
  ...carried,
8399
9297
  assetContract: ASSET_CONTRACT,
8400
9298
  serverVersion: version,
8401
- ...bucket === void 0 ? {} : { bucket }
9299
+ ...bucket === void 0 ? {} : { bucket },
9300
+ // An install from before the mirrors gains both here; one that turned a mirror off keeps it off.
9301
+ mirrors: carried.mirrors ?? DEFAULT_MIRRORS
9302
+ };
9303
+ const previousToken = parseSecrets(previous.files.env).dockerHubToken;
9304
+ const secrets = {
9305
+ ...parseSecrets(previous.files.identity),
9306
+ ...previousToken === void 0 ? {} : { dockerHubToken: previousToken }
8402
9307
  };
8403
- const secrets = parseSecrets(previous.files.identity);
8404
9308
  const files = generationFiles(config, secrets, assets, previous.files.identity);
8405
9309
  const parsed = parseServerConfig(files.config);
8406
9310
  if (renderSecrets(parseSecrets(files.env), parsed) !== files.env)
8407
9311
  throw setupError("Invalid upgrade configuration.");
8408
- const edgeImage = files.edge === void 0 ? {} : { edgeImage: edgeImageOf(files.edge) };
9312
+ const edgeImage = {
9313
+ ...files.edge === void 0 ? {} : { edgeImage: edgeImageOf(files.edge) },
9314
+ ...files.mirrors === void 0 ? {} : { mirrorImages: mirrorImagesOf(files.mirrors) }
9315
+ };
8409
9316
  await checkLocalImages(
8410
9317
  runtime,
8411
9318
  { config, ...edgeImage },
@@ -8490,6 +9397,7 @@ var upgradeServer = async (args, runtime, store, existing) => {
8490
9397
  );
8491
9398
  }
8492
9399
  runtime.writeLine(`Upgraded to ${version}. Retained database backup: ${backup.directory}`);
9400
+ for (const line of mirrorsChangedLines(existing.config.mirrors, config)) runtime.writeLine(line);
8493
9401
  pruneUpgradeBackups(runtime, store, backup, options.keepBackups);
8494
9402
  };
8495
9403
  var observeEdgeCertificate = async (runtime, installation, host) => {
@@ -8593,6 +9501,75 @@ var exposureReportOf = (value) => {
8593
9501
  const parsed = items.flatMap(exposureItemOf);
8594
9502
  return parsed.length === items.length && parsed.length > 0 ? { declared, items: parsed } : null;
8595
9503
  };
9504
+ var mirrorStatusLines = async (runtime, installation, runningServices) => {
9505
+ const mirrors = installation.config.mirrors;
9506
+ if (mirrors === void 0) return [];
9507
+ const run2 = (args) => runtime.run("docker", serverComposeArgs(composeTarget(installation), args), {
9508
+ timeoutMs: serverProcessDeadlines.ordinary
9509
+ });
9510
+ const failed = (output) => output.status !== 0 || output.error !== void 0 ? outputDetail(output) : null;
9511
+ const diskOf = async (service, directory) => {
9512
+ const probe = await run2(["exec", "-T", service, "sh", "-c", mirrorDiskProbe(directory)]);
9513
+ return failed(probe) ?? mirrorDiskOf(probe.stdout) ?? "du and df answered nothing readable";
9514
+ };
9515
+ const lines = [];
9516
+ const npmRunning = runningServices.includes("npm-mirror");
9517
+ if (mirrors.npm === null || !npmRunning) {
9518
+ lines.push(observedNpmMirrorLine(mirrors, { running: false, disk: "", traffic: "" }));
9519
+ } else {
9520
+ const log = await run2([
9521
+ "logs",
9522
+ "--no-color",
9523
+ "--no-log-prefix",
9524
+ "--since",
9525
+ "24h",
9526
+ "npm-mirror"
9527
+ ]);
9528
+ lines.push(
9529
+ observedNpmMirrorLine(mirrors, {
9530
+ running: true,
9531
+ disk: await diskOf("npm-mirror", "/var/cache/npm-mirror"),
9532
+ traffic: failed(log) ?? npmMirrorTraffic(log.stdout)
9533
+ })
9534
+ );
9535
+ }
9536
+ const dockerRunning = runningServices.includes("docker-mirror");
9537
+ if (mirrors.docker === null || !dockerRunning) {
9538
+ lines.push(
9539
+ observedDockerMirrorLine(mirrors, { running: false, disk: "", traffic: "", startedAt: null })
9540
+ );
9541
+ } else {
9542
+ const metrics = await run2([
9543
+ "exec",
9544
+ "-T",
9545
+ "docker-mirror",
9546
+ "wget",
9547
+ "-q",
9548
+ "-O",
9549
+ "-",
9550
+ "http://127.0.0.1:5001/metrics"
9551
+ ]);
9552
+ const started = await runtime.run("docker", [
9553
+ "--context",
9554
+ installation.config.dockerContext,
9555
+ "container",
9556
+ "inspect",
9557
+ "--format",
9558
+ "{{.State.StartedAt}}",
9559
+ DOCKER_MIRROR_CONTAINER
9560
+ ]);
9561
+ const startedAt = failed(started) === null ? started.stdout.trim().replace(/\.\d+Z$/, "Z") : "";
9562
+ lines.push(
9563
+ observedDockerMirrorLine(mirrors, {
9564
+ running: true,
9565
+ disk: await diskOf("docker-mirror", "/var/lib/registry"),
9566
+ traffic: failed(metrics) ?? dockerMirrorTraffic(metrics.stdout) ?? "the registry reported no proxy counters",
9567
+ startedAt: startedAt === "" ? null : startedAt
9568
+ })
9569
+ );
9570
+ }
9571
+ return lines;
9572
+ };
8596
9573
  var serverStatus = async (runtime, installation) => {
8597
9574
  const { config } = installation;
8598
9575
  runtime.writeLine(`Pinned Mend ${config.serverVersion} at ${config.appUrl}`);
@@ -8616,6 +9593,8 @@ var serverStatus = async (runtime, installation) => {
8616
9593
  })
8617
9594
  );
8618
9595
  }
9596
+ for (const line of await mirrorStatusLines(runtime, installation, runningServices))
9597
+ runtime.writeLine(line);
8619
9598
  if (!runningServices.includes("mend")) {
8620
9599
  runtime.writeLine("Mend is stopped. No health claim was made.");
8621
9600
  return;
@@ -8684,7 +9663,10 @@ var manageServer = async (command, args, runtime, store) => {
8684
9663
  "Mend",
8685
9664
  "Postgres",
8686
9665
  ...installation.config.bucket === "garage" ? ["Garage"] : [],
8687
- ...installation.config.edgeHost === void 0 ? [] : ["the edge"]
9666
+ ...installation.config.edgeHost === void 0 ? [] : ["the edge"],
9667
+ ...mirrorServices(installation.config.mirrors).map(
9668
+ (service) => service === "npm-mirror" ? "the npm mirror" : "the Docker mirror"
9669
+ )
8688
9670
  ];
8689
9671
  runtime.writeLine(
8690
9672
  `${stopped.slice(0, -1).join(", ")} and ${stopped.at(-1)} stopped. Volumes, configuration and workspace containers are retained.`
@@ -8728,7 +9710,13 @@ var nodeServerRuntime = () => {
8728
9710
  `),
8729
9711
  dockerDaemonFacts: hostDockerDaemonFacts,
8730
9712
  readLogin: (configDir) => savedLogin(configDir, environment),
8731
- probeSsh: probeSshFromHere
9713
+ probeSsh: probeSshFromHere,
9714
+ readStdin: async () => {
9715
+ const chunks = [];
9716
+ for await (const chunk of process.stdin)
9717
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : Buffer.from(chunk));
9718
+ return Buffer.concat(chunks).toString("utf8");
9719
+ }
8732
9720
  };
8733
9721
  };
8734
9722
  var sshBannerAt = async (host, port, timeoutMs) => {
@@ -9930,6 +10918,7 @@ var describeUninstall = async (runtime, scope) => {
9930
10918
  appUrl: read.value.config.appUrl,
9931
10919
  dockerContext: read.value.config.dockerContext,
9932
10920
  edgeHost: read.value.config.edgeHost ?? null,
10921
+ mirrors: mirrorServices(read.value.config.mirrors),
9933
10922
  generations: countEntries(path20.join(configDir, "generations")),
9934
10923
  backups: countEntries(path20.join(configDir, "backups"))
9935
10924
  };
@@ -9959,8 +10948,12 @@ var planLines = (plan, configDir) => {
9959
10948
  `server Mend ${version}${appUrl === "" ? "" : ` at ${appUrl}`}${dockerContext === "" ? "" : ` \xB7 docker context ${dockerContext}`}`
9960
10949
  );
9961
10950
  if (dockerContext !== "") {
9962
- const edge = edgeHost === null ? [] : ["edge"];
9963
- const edgeVolumes = edgeHost === null ? [] : ["mend-edge-data", "mend-edge-config"];
10951
+ const mirrors = plan.server.mirrors ?? [];
10952
+ const edge = [...edgeHost === null ? [] : ["edge"], ...mirrors];
10953
+ const edgeVolumes = [
10954
+ ...edgeHost === null ? [] : ["mend-edge-data", "mend-edge-config"],
10955
+ ...mirrors.map((service) => `mend-${service}`)
10956
+ ];
9964
10957
  lines.push(
9965
10958
  ` containers ${["mend", "postgres", "garage", ...edge].join(", ")} \xB7 volumes ${[MEND_DOCKER_NAMESPACE_WITH_GARAGE.store, ...secondaryVolumesOf(MEND_DOCKER_NAMESPACE_WITH_GARAGE), "mend-config", "mend-ssh", "mend-postgres", ...edgeVolumes].join(", ")} \xB7 image ghcr.io/sealant-sh/mend:${version}${edgeHost === null ? "" : ` \xB7 the edge for ${edgeHost}`}`
9966
10959
  );
@@ -10019,7 +11012,9 @@ var removeServer = async (runtime, failures, leftovers) => {
10019
11012
  `docker compose down failed: ${(down.error ?? down.stderr.trim()) || "no output"}. Containers and files are retained; fix Docker and run mend uninstall again.`
10020
11013
  );
10021
11014
  }
10022
- server.writeLine("removed containers mend, postgres, garage and the Compose-owned volumes");
11015
+ server.writeLine(
11016
+ `removed containers ${["mend", "postgres", "garage", ...mirrorServices(installation.config.mirrors)].join(", ")} and the Compose-owned volumes`
11017
+ );
10023
11018
  const namespace = installation.config.bucket === "garage" ? MEND_DOCKER_NAMESPACE_WITH_GARAGE : MEND_DOCKER_NAMESPACE;
10024
11019
  const dataVolumes = [namespace.store, ...secondaryVolumesOf(namespace)];
10025
11020
  const identity = store.readIdentity();
@@ -11468,8 +12463,9 @@ var printService = (config, service) => {
11468
12463
  const status = service.status === "reachable" ? green7(service.status) : amber2(service.status);
11469
12464
  const port = `:${service.workspacePort ?? "?"}${service.protocol === "udp" ? "/udp" : ""}`;
11470
12465
  const statusPad = " ".repeat(Math.max(1, 12 - service.status.length));
12466
+ const processLine2 = service.processId === null ? "no process" : `process ${service.processId.slice(0, 8)}`;
11471
12467
  say7(
11472
- `${(service.label ?? service.id.slice(0, 8)).padEnd(14)} ${status}${statusPad}${dim7(port.padEnd(7))} ${dim7(service.id.slice(0, 8))}`
12468
+ `${(service.label ?? service.id.slice(0, 8)).padEnd(14)} ${status}${statusPad}${dim7(port.padEnd(7))} ${dim7(`service ${service.id.slice(0, 8)} \xB7 ${processLine2}`)}`
11473
12469
  );
11474
12470
  printServiceAccess(config, service);
11475
12471
  printWorkspaceTtlFailure(service);
@@ -11512,8 +12508,30 @@ var serviceAdd = async (config, args) => {
11512
12508
  say7(dim7(` nothing answered on :${port} yet \u2014 the URL goes live when something listens`));
11513
12509
  }
11514
12510
  };
11515
- var serviceList = async (config) => {
12511
+ var serviceList = async (config, args) => {
12512
+ const unknown = args.find((arg) => arg !== "--json");
12513
+ if (unknown !== void 0) {
12514
+ return fail7(`unknown argument ${unknown} \xB7 ${usageOf("service list")}`);
12515
+ }
11516
12516
  const services = await fetchServices(config);
12517
+ if (args.includes("--json")) {
12518
+ printJson({
12519
+ version: 1,
12520
+ services: services.map((service) => ({
12521
+ id: service.id,
12522
+ name: service.label,
12523
+ sessionId: service.sessionId,
12524
+ processId: service.processId,
12525
+ status: service.status,
12526
+ workspacePort: service.workspacePort,
12527
+ protocol: service.protocol,
12528
+ hostPort: service.hostPort,
12529
+ authority: service.authority,
12530
+ browserUrl: service.browserUrl
12531
+ }))
12532
+ });
12533
+ return;
12534
+ }
11517
12535
  if (services.length === 0) {
11518
12536
  say7(dim7("no live services \u2014 mend service add <port> adopts a listening one"));
11519
12537
  return;
@@ -11552,48 +12570,184 @@ var autoConnect = async (config, service) => {
11552
12570
  say7(dim7(" remote server \u2014 tunneling the port here \xB7 Ctrl-C stops the tunnel, not the Service"));
11553
12571
  await tunnelServices(config, [service], null);
11554
12572
  };
11555
- var SERVICE_WAIT_MS = (() => {
12573
+ var SERVICE_WAIT_DEFAULT_MS = (() => {
11556
12574
  const configured = Number(process.env["MEND_SERVICE_WAIT_MS"]);
11557
- return Number.isFinite(configured) && configured > 0 ? configured : 9e4;
12575
+ return Number.isFinite(configured) && configured > 0 ? configured : 10 * 6e4;
11558
12576
  })();
11559
- var withinServiceWait = async (work, deadline) => {
11560
- const bounded = await beforeDeadline(work, clock, deadline);
12577
+ var SERVICE_RUN_VALUED = /* @__PURE__ */ new Set(["--port", "--name", "--timeout"]);
12578
+ var serviceWaitOf = (head) => {
12579
+ const timeoutFlag = head.indexOf("--timeout");
12580
+ const timeoutText = timeoutFlag === -1 ? void 0 : head[timeoutFlag + 1];
12581
+ if (!head.includes("--wait")) {
12582
+ return timeoutFlag === -1 ? null : fail7("--timeout bounds --wait: pass --wait too, or leave --timeout out");
12583
+ }
12584
+ const timeoutMs = timeoutFlag === -1 ? SERVICE_WAIT_DEFAULT_MS : timeoutText === void 0 ? null : parseDuration(timeoutText);
12585
+ if (timeoutMs === null) return fail7(`${DURATION_ISSUE} \xB7 ${usageOf("service run")}`);
12586
+ return { deadline: clock.now() + timeoutMs, timeoutMs };
12587
+ };
12588
+ var serviceRunPositionals = (head) => head.filter(
12589
+ (arg, index) => !arg.startsWith("--") && !SERVICE_RUN_VALUED.has(head[index - 1] ?? "")
12590
+ );
12591
+ var failWith = (code, message) => {
12592
+ process.stderr.write(`mend: ${redactCredentials(message)}
12593
+ `);
12594
+ return exitFlushed(code);
12595
+ };
12596
+ var withinServiceWait = async (work, wait) => {
12597
+ const bounded = await beforeDeadline(work, clock, wait?.deadline ?? null);
11561
12598
  if (bounded.done) return bounded.value;
11562
- process.stderr.write(
11563
- `mend: no answer within ${Math.round(SERVICE_WAIT_MS / 1e3)} s \xB7 the Service may still be starting \xB7 mend service list
11564
- `
12599
+ return failWith(
12600
+ WAIT_TIMED_OUT,
12601
+ `Mend gave no answer within ${durationLine(wait?.timeoutMs ?? 0)} \xB7 nothing was started`
12602
+ );
12603
+ };
12604
+ var errorText = (error) => error instanceof Error ? error.message : String(error);
12605
+ var exitCodeText = (exitCode) => exitCode === null ? "no exit code reported" : `code ${exitCode}`;
12606
+ var readServiceStart = async (config, sessionId) => {
12607
+ const [services, sessionStatus] = await Promise.all([
12608
+ request(config, "GET", "/services?all=1"),
12609
+ request(config, "GET", `/sessions/${sessionId}`).then(
12610
+ (detail) => detail.session.status,
12611
+ (error) => {
12612
+ if (error instanceof MendRequestError && error.kind === "http" && error.status === 404) {
12613
+ return null;
12614
+ }
12615
+ throw error;
12616
+ }
12617
+ )
12618
+ ]);
12619
+ return { services, sessionStatus };
12620
+ };
12621
+ var reportAnswered = (config, service) => {
12622
+ const name = service.label;
12623
+ say7(`${green7("\u2713")} Service ${name} \xB7 ${service.status}`);
12624
+ printServiceEndpoint(config, service, false);
12625
+ say7(dim7(` logs: mend service logs ${name}`));
12626
+ if (willAutoConnect(config, service, false)) say7(dim7(` connect: mend service connect ${name}`));
12627
+ };
12628
+ var startServiceWaited = async (config, session, target, wait, spinner, start) => {
12629
+ const { name, port } = target;
12630
+ const startId = crypto.randomUUID();
12631
+ const after = durationLine(wait.timeoutMs);
12632
+ const stillStarting = (processId, more = "") => failWith(
12633
+ WAIT_TIMED_OUT,
12634
+ `${name} \xB7 still starting after ${after} \xB7 ${processId === null ? "" : `process ${processId.slice(0, 8)} runs \xB7 `}:${port} has not answered${more} \xB7 the Service keeps starting \xB7 mend logs --service ${name} --follow`
12635
+ );
12636
+ const ended = (processId, status, exitCode, more) => failWith(
12637
+ SERVICE_PROCESS_ENDED,
12638
+ `${name} \xB7 process ${processId.slice(0, 8)} ${status} \xB7 ${exitCodeText(exitCode)} \xB7 before :${port} answered \xB7 ${more}`
12639
+ );
12640
+ const posted = await withSpinner(
12641
+ spinner,
12642
+ beforeDeadline(
12643
+ start(startId).then(
12644
+ (view) => ({ ok: true, view }),
12645
+ (error) => ({ ok: false, error })
12646
+ ),
12647
+ clock,
12648
+ wait.deadline
12649
+ )
11565
12650
  );
11566
- return exitFlushed(WAIT_TIMED_OUT);
11567
- };
11568
- var failUnlessAnswered = (config, service, wait) => {
11569
- if (!wait) return;
11570
- const name = service.label ?? service.id.slice(0, 8);
11571
- if (service.status !== "reachable") {
11572
- fail7(
11573
- `nothing answered on :${service.workspacePort} \xB7 ${service.status} \xB7 the Service keeps running \xB7 mend service logs ${name}`
12651
+ if (!posted.done) return stillStarting(null, " \xB7 the start request has not returned");
12652
+ let attemptId = null;
12653
+ if (posted.value.ok) {
12654
+ const { view } = posted.value;
12655
+ attemptId = answeredAttemptId(view, startId);
12656
+ if (attemptId !== null) {
12657
+ const first = serviceStartStateOf(
12658
+ { services: [view], sessionStatus: session.status },
12659
+ { attemptId, sessionId: session.id, startId }
12660
+ );
12661
+ if (first.kind === "answered") return reportAnswered(config, flattenService(view));
12662
+ if (first.kind === "process-ended") {
12663
+ return ended(first.processId, first.status, first.exitCode, `mend logs --service ${name}`);
12664
+ }
12665
+ }
12666
+ say7(
12667
+ `${amber2("\xB7")} ${name} \xB7 ${attemptId === null ? "started" : `process ${attemptId.slice(0, 8)} runs`} \xB7 :${port} has not answered yet \xB7 waiting up to ${after}; Mend probes the port every 20 s`
11574
12668
  );
11575
- }
11576
- if (willAutoConnect(config, service, false)) {
11577
- say7(dim7(` connect: mend service connect ${name}`));
12669
+ } else {
12670
+ const { error } = posted.value;
12671
+ if (!mayStillBeWorking(error)) {
12672
+ const read = await readWithin(
12673
+ () => request(config, "GET", "/services?all=1"),
12674
+ clock,
12675
+ wait.deadline
12676
+ ).catch(() => null);
12677
+ const found = read?.done === true ? findStartAttempt(read.value, { attemptId: null, sessionId: session.id, startId }) : void 0;
12678
+ if (found !== void 0 && found.attempt.exitedAt !== null) {
12679
+ return ended(
12680
+ found.attempt.id,
12681
+ found.attempt.status,
12682
+ found.attempt.exitCode,
12683
+ errorText(error)
12684
+ );
12685
+ }
12686
+ return failWith(1, errorText(error));
12687
+ }
12688
+ say7(dim7(` the start got no answer (${errorText(error)}) \xB7 looking for the attempt it began\u2026`));
12689
+ }
12690
+ const started = clock.now();
12691
+ let line = `${name} \xB7 starting \xB7 waiting for :${port} to answer\u2026`;
12692
+ let nextReport = started + 6e4;
12693
+ const outcome = await withSpinner(
12694
+ () => line,
12695
+ waitForServiceStart({
12696
+ read: () => readServiceStart(config, session.id),
12697
+ target: { attemptId, sessionId: session.id, startId },
12698
+ deadline: wait.deadline,
12699
+ ...clock,
12700
+ onStarting: (state) => {
12701
+ const running = state.processId === null ? "" : ` \xB7 process ${state.processId.slice(0, 8)} runs`;
12702
+ line = `${name} \xB7 starting${running} \xB7 :${port} has not answered`;
12703
+ if (chrome.isTTY !== true && clock.now() >= nextReport) {
12704
+ nextReport = clock.now() + 6e4;
12705
+ say7(dim7(` ${line} \xB7 ${durationLine(clock.now() - started)} so far`));
12706
+ }
12707
+ }
12708
+ })
12709
+ ).catch((error) => failFlushed(errorText(error)));
12710
+ switch (outcome.kind) {
12711
+ case "answered":
12712
+ return reportAnswered(config, flattenService(outcome.view));
12713
+ case "process-ended":
12714
+ return ended(
12715
+ outcome.processId,
12716
+ outcome.status,
12717
+ outcome.exitCode,
12718
+ `mend logs --service ${name}`
12719
+ );
12720
+ case "workspace-ended":
12721
+ return failWith(
12722
+ SERVICE_WORKSPACE_ENDED,
12723
+ `${name} \xB7 session ${session.id.slice(0, 8)} no longer exists, and its workspace went with it \xB7 before :${port} answered`
12724
+ );
12725
+ case "timeout":
12726
+ return stillStarting(outcome.last?.kind === "starting" ? outcome.last.processId : null);
12727
+ case "no-attempt":
12728
+ return failWith(
12729
+ 1,
12730
+ `${name} \xB7 the start got no answer, and no attempt carrying its id appeared within a minute (a server older than this CLI stamps none) \xB7 mend service list`
12731
+ );
11578
12732
  }
11579
12733
  };
11580
12734
  var serviceRun = async (config, args) => {
11581
12735
  const dashdash = args.indexOf("--");
11582
12736
  const usage2 = usageOf("service run");
11583
- const wait = (dashdash === -1 ? args : args.slice(0, dashdash)).includes("--wait");
11584
- const deadline = wait ? clock.now() + SERVICE_WAIT_MS : null;
12737
+ const head = dashdash === -1 ? args : args.slice(0, dashdash);
12738
+ const wait = serviceWaitOf(head);
11585
12739
  if (dashdash === -1) {
11586
- const positionals = args.filter((a) => !a.startsWith("--"));
12740
+ const positionals = serviceRunPositionals(head);
11587
12741
  const name2 = positionals.at(-1);
11588
12742
  if (name2 === void 0) return fail7(usage2);
11589
12743
  const prefix2 = positionals.length > 1 ? positionals[0] : void 0;
11590
12744
  const session2 = await withinServiceWait(
11591
12745
  resolveLiveSession(config, prefix2, "service run"),
11592
- deadline
12746
+ wait
11593
12747
  );
11594
12748
  const recipes = await withinServiceWait(
11595
12749
  api(config, "GET", `/sessions/${session2.id}/recipes`),
11596
- deadline
12750
+ wait
11597
12751
  );
11598
12752
  const recipe = recipes.find((entry) => entry.name === name2);
11599
12753
  if (recipe === void 0) {
@@ -11602,34 +12756,43 @@ var serviceRun = async (config, args) => {
11602
12756
  recipes.length === 0 ? `no mend.toml recipes in this worktree \u2014 declare [service.${name2}] first` : `no recipe named "${name2}" \u2014 declared: ${known}`
11603
12757
  );
11604
12758
  }
11605
- if (wait && recipe.protocol === "udp") {
11606
- return fail7(`--wait needs a TCP port: ${recipe.name} is UDP, which has no probe`);
11607
- }
11608
- if (wait && recipe.command === null) {
11609
- return fail7(
11610
- `--wait needs a command Mend starts: ${recipe.name} declares only a port, which Mend adopts with one probe \xB7 run it without --wait, or give the recipe a command`
12759
+ const startRecipe = (startId) => request(config, "POST", `/sessions/${session2.id}/services/recipe`, {
12760
+ name: recipe.name,
12761
+ startId
12762
+ });
12763
+ if (wait !== null) {
12764
+ if (recipe.protocol === "udp") {
12765
+ return fail7(`--wait needs a TCP port: ${recipe.name} is UDP, which has no probe`);
12766
+ }
12767
+ if (recipe.command === null) {
12768
+ return fail7(
12769
+ `--wait needs a command Mend starts: ${recipe.name} declares only a port, which Mend adopts with one probe \xB7 run it without --wait, or give the recipe a command`
12770
+ );
12771
+ }
12772
+ return startServiceWaited(
12773
+ config,
12774
+ session2,
12775
+ { name: recipe.name, port: recipe.port },
12776
+ wait,
12777
+ `starting ${recipe.name} \u2014 waiting for :${recipe.port} to answer\u2026`,
12778
+ startRecipe
11611
12779
  );
11612
12780
  }
11613
12781
  const service2 = await withSpinner(
11614
12782
  recipe.command === null ? `adopting ${recipe.name} on :${recipe.port}\u2026` : recipe.protocol === "udp" ? `starting ${recipe.name} (udp :${recipe.port})\u2026` : `starting ${recipe.name} \u2014 waiting for :${recipe.port} to answer\u2026`,
11615
- withinServiceWait(
11616
- mutateService(config, "POST", `/sessions/${session2.id}/services/recipe`, {
11617
- name: recipe.name
11618
- }),
11619
- deadline
11620
- )
12783
+ mutateService(config, "POST", `/sessions/${session2.id}/services/recipe`, {
12784
+ name: recipe.name
12785
+ })
11621
12786
  );
11622
- const tunneling2 = willAutoConnect(config, service2, args.includes("--no-connect") || wait);
12787
+ const tunneling2 = willAutoConnect(config, service2, args.includes("--no-connect"));
11623
12788
  say7(`${green7("\u2713")} Service ${service2.label ?? ""} \xB7 ${service2.status}`);
11624
12789
  printServiceEndpoint(config, service2, tunneling2);
11625
12790
  say7(dim7(` logs: mend service logs ${service2.label ?? service2.id.slice(0, 8)}`));
11626
- failUnlessAnswered(config, service2, wait);
11627
12791
  if (tunneling2) await autoConnect(config, service2);
11628
12792
  return;
11629
12793
  }
11630
12794
  const argv = args.slice(dashdash + 1);
11631
12795
  if (argv.length === 0) return fail7(usage2);
11632
- const head = args.slice(0, dashdash);
11633
12796
  const portFlag = head.indexOf("--port");
11634
12797
  const port = portFlag === -1 ? Number.NaN : Number(head[portFlag + 1]);
11635
12798
  if (!Number.isInteger(port) || port < 1 || port > 65535) return fail7(usage2);
@@ -11641,34 +12804,33 @@ var serviceRun = async (config, args) => {
11641
12804
  if (http && https) return fail7(usage2);
11642
12805
  const browserScheme = https ? "https" : http ? "http" : null;
11643
12806
  if (protocol === "udp" && browserScheme !== null) return fail7(usage2);
11644
- if (protocol === "udp" && wait) {
12807
+ if (protocol === "udp" && wait !== null) {
11645
12808
  return fail7("--wait needs a TCP port: UDP has no probe, so nothing could be waited for");
11646
12809
  }
11647
- const prefix = head.find(
11648
- (a, i) => !a.startsWith("--") && (portFlag === -1 || i !== portFlag + 1) && (nameFlag === -1 || i !== nameFlag + 1)
11649
- );
11650
- const session = await withinServiceWait(
11651
- resolveLiveSession(config, prefix, "service run"),
11652
- deadline
11653
- );
12810
+ const prefix = serviceRunPositionals(head)[0];
12811
+ const session = await withinServiceWait(resolveLiveSession(config, prefix, "service run"), wait);
12812
+ const body = { argv, port, name, protocol, browserScheme };
12813
+ if (wait !== null) {
12814
+ return startServiceWaited(
12815
+ config,
12816
+ session,
12817
+ { name: name ?? argv[0] ?? "service", port },
12818
+ wait,
12819
+ `starting ${name ?? argv[0]} \u2014 waiting for :${port} to answer\u2026`,
12820
+ (startId) => request(config, "POST", `/sessions/${session.id}/services/run`, {
12821
+ ...body,
12822
+ startId
12823
+ })
12824
+ );
12825
+ }
11654
12826
  const service = await withSpinner(
11655
12827
  protocol === "udp" ? `starting ${name ?? argv[0]} (udp :${port})\u2026` : `starting ${name ?? argv[0]} \u2014 waiting for :${port} to answer\u2026`,
11656
- withinServiceWait(
11657
- mutateService(config, "POST", `/sessions/${session.id}/services/run`, {
11658
- argv,
11659
- port,
11660
- name,
11661
- protocol,
11662
- browserScheme
11663
- }),
11664
- deadline
11665
- )
12828
+ mutateService(config, "POST", `/sessions/${session.id}/services/run`, body)
11666
12829
  );
11667
- const tunneling = willAutoConnect(config, service, head.includes("--no-connect") || wait);
12830
+ const tunneling = willAutoConnect(config, service, head.includes("--no-connect"));
11668
12831
  say7(`${green7("\u2713")} Service ${service.label ?? ""} \xB7 ${service.status}`);
11669
12832
  printServiceEndpoint(config, service, tunneling);
11670
12833
  say7(dim7(` logs: mend service logs ${service.label ?? service.id.slice(0, 8)}`));
11671
- failUnlessAnswered(config, service, wait);
11672
12834
  if (tunneling) await autoConnect(config, service);
11673
12835
  };
11674
12836
  var serviceLogs = async (config, args) => {
@@ -11896,8 +13058,9 @@ var serviceCommand = async (config, args) => {
11896
13058
  case "connect":
11897
13059
  return serviceConnect(config, rest);
11898
13060
  case "list":
13061
+ return serviceList(config, rest);
11899
13062
  case void 0:
11900
- return serviceList(config);
13063
+ return serviceList(config, []);
11901
13064
  case "logs":
11902
13065
  return serviceLogs(config, rest);
11903
13066
  case "restart":
@@ -13030,7 +14193,7 @@ var printJson = (value) => {
13030
14193
  `);
13031
14194
  };
13032
14195
  var outputOnTerminal = false;
13033
- var stdoutErrorsHandled = false;
14196
+ var recordedOutput = false;
13034
14197
  var outputStopped = false;
13035
14198
  var restoreTerminal = () => {
13036
14199
  if (!outputOnTerminal || process.stdout.isTTY !== true) return;
@@ -13038,13 +14201,17 @@ var restoreTerminal = () => {
13038
14201
  process.stdout.write(`${TERMINAL_MODES_RESET}\x1B[0m`);
13039
14202
  };
13040
14203
  process.once("exit", restoreTerminal);
14204
+ var onClosedPipe = (error) => {
14205
+ if (error.code !== "EPIPE") throw error;
14206
+ if (recordedOutput) return;
14207
+ process.exit(0);
14208
+ };
14209
+ process.stdout.on("error", onClosedPipe);
14210
+ process.stderr.on("error", onClosedPipe);
13041
14211
  var writeOutput = (bytes) => {
13042
14212
  if (outputStopped) return Promise.reject(new Error("stopped by a signal"));
13043
14213
  if (process.stdout.isTTY === true) outputOnTerminal = true;
13044
- if (!stdoutErrorsHandled) {
13045
- stdoutErrorsHandled = true;
13046
- process.stdout.on("error", () => void 0);
13047
- }
14214
+ recordedOutput = true;
13048
14215
  return new Promise((resolve8, reject) => {
13049
14216
  process.stdout.write(bytes, (error) => {
13050
14217
  if (error === null || error === void 0) resolve8();
@@ -13224,26 +14391,57 @@ var resolveAnySession = async (config, word) => {
13224
14391
  if ("error" in picked) return fail7(picked.error);
13225
14392
  return picked.session;
13226
14393
  };
14394
+ var serviceLogsTarget = async (config, needle, sessionWord) => {
14395
+ const session = sessionWord === null ? null : await resolveAnySession(config, sessionWord);
14396
+ const picked = pickServiceAttempt(
14397
+ await fetchServiceViews(config, true),
14398
+ needle,
14399
+ session?.id ?? null
14400
+ );
14401
+ if ("error" in picked) return fail7(picked.error);
14402
+ return { processId: picked.processId, again: `mend logs --service ${needle}` };
14403
+ };
13227
14404
  var logsCommand = async (config, args) => {
13228
14405
  chromeToStderr();
13229
14406
  const parsed = parseLogsArgs(args);
13230
14407
  if ("error" in parsed) return fail7(`${parsed.error} \xB7 ${usageOf("logs")}`);
13231
- const { session: word, follow, from, process: processPrefix } = parsed.args;
13232
- const session = word === null ? await resolveLiveSession(config, void 0, "logs") : await resolveAnySession(config, word);
13233
- const detail = await api(config, "GET", `/sessions/${session.id}`);
13234
- const picked = pickProcess(detail, processPrefix);
13235
- if ("error" in picked) return fail7(`session ${session.id.slice(0, 8)} \xB7 ${picked.error}`);
13236
- const target = picked.process;
13237
- if (target.id === void 0 || target.sealantSessionId === null) {
13238
- return fail7(
13239
- `session ${session.id.slice(0, 8)} \xB7 this process has no recorded terminal (an adopted port, or a server older than process ids)`
13240
- );
14408
+ const { session: word, follow, from, process: processPrefix, service } = parsed.args;
14409
+ let target;
14410
+ if (service !== null) {
14411
+ target = await serviceLogsTarget(config, service, word);
14412
+ } else {
14413
+ const session = word === null ? await resolveLiveSession(config, void 0, "logs") : await resolveAnySession(config, word);
14414
+ const id8 = session.id.slice(0, 8);
14415
+ const detail = await api(config, "GET", `/sessions/${session.id}`);
14416
+ const picked = pickProcess(detail, processPrefix);
14417
+ if ("error" in picked) {
14418
+ const asService = processPrefix === null ? null : pickServiceAttempt(await fetchServiceViews(config, true), processPrefix, session.id);
14419
+ if (asService === null || "error" in asService && !asService.named) {
14420
+ return fail7(`session ${id8} \xB7 ${picked.error}`);
14421
+ }
14422
+ if ("error" in asService) return fail7(`session ${id8} \xB7 ${asService.error}`);
14423
+ target = {
14424
+ processId: asService.processId,
14425
+ again: `mend logs ${id8} --process ${processPrefix}`
14426
+ };
14427
+ } else {
14428
+ const chosen = picked.process;
14429
+ if (chosen.id === void 0 || chosen.sealantSessionId === null) {
14430
+ return fail7(
14431
+ `session ${id8} \xB7 this process has no recorded terminal (an adopted port, or a server older than process ids)`
14432
+ );
14433
+ }
14434
+ target = {
14435
+ processId: chosen.id,
14436
+ again: processPrefix === null ? `mend logs ${id8}` : `mend logs ${id8} --process ${processPrefix}`
14437
+ };
14438
+ }
13241
14439
  }
13242
14440
  stopOutputOnSignal(
13243
- follow ? `stopped watching \xB7 the process keeps running \xB7 mend logs ${session.id.slice(0, 8)} --follow` : "stopped reading the record"
14441
+ follow ? `stopped watching \xB7 the process keeps running \xB7 ${target.again} --follow` : "stopped reading the record"
13244
14442
  );
13245
14443
  try {
13246
- const last = await writeProcessLogs(config, target.id, from, follow);
14444
+ const last = await writeProcessLogs(config, target.processId, from, follow);
13247
14445
  restoreTerminal();
13248
14446
  if (follow) say7(dim7(`
13249
14447
  record ended \xB7 ${last.status} \xB7 next sequence ${last.next}`));
@@ -13260,7 +14458,7 @@ var waitCommand = async (config, args) => {
13260
14458
  if ("error" in parsed) return fail7(`${parsed.error} \xB7 ${usageOf("wait")}`);
13261
14459
  const { session: word, timeoutMs, json: json2, process: processPrefix } = parsed.args;
13262
14460
  const deadline = timeoutMs === null ? null : clock.now() + timeoutMs;
13263
- const after = `${Math.round((timeoutMs ?? 0) / 1e3)} s`;
14461
+ const after = durationLine(timeoutMs ?? 0);
13264
14462
  const timedOut = async (session2, processId2, last) => {
13265
14463
  say7(
13266
14464
  session2 === null ? `${amber2("\xB7")} no session found within ${after}` : `${amber2("\xB7")} session ${session2.id.slice(0, 8)} \xB7 still running after ${after} \xB7 the command keeps running`
@@ -13822,7 +15020,7 @@ var dashboard = async (config, options = {}) => {
13822
15020
  );
13823
15021
  process.exit(rerun.status ?? 0);
13824
15022
  }
13825
- const { runDashboard } = await import("./dashboard-DQB5RNXJ.js");
15023
+ const { runDashboard } = await import("./dashboard-RAZDVFR7.js");
13826
15024
  const agentShare = await startShareIfBridge(config);
13827
15025
  const tunnels = options.noTunnel === true || serverIsLocal(config) ? null : createServiceTunnels({
13828
15026
  listServices: () => fetchServices(config),