@sealant/mend 0.36.0-next.663 → 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-VOP6USOZ.js → chunk-TP4A72RF.js} +43 -2
  2. package/dist/{dashboard-CEJN6IOO.js → dashboard-RAZDVFR7.js} +8 -2
  3. package/dist/main.js +1521 -175
  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 +2 -2
  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 +42 -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-VOP6USOZ.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"];
@@ -861,6 +1279,9 @@ var isTenancy = (value) => TENANCIES.some((tenancy) => tenancy === value);
861
1279
  var EDGE_IMAGE = "caddy:2.10-alpine";
862
1280
  var EDGE_COMPOSE_FILE = "compose.edge.yaml";
863
1281
  var POSTURE_COMPOSE_FILE = "compose.posture.yaml";
1282
+ var DECLARABLE_ITEMS = ["core-private", "edge-tls", "workspace-ssh"];
1283
+ var isDeclarableItem = (value) => DECLARABLE_ITEMS.some((item) => item === value);
1284
+ var publishedAddress = (address, port) => `${net2.isIP(address) === 6 ? `[${address}]` : address}:${port}`;
864
1285
  var parseEdgeHost = (value) => {
865
1286
  const host = value.trim().toLowerCase().replace(/\.$/, "");
866
1287
  const labels = host.split(".");
@@ -876,7 +1297,11 @@ var postureEnvironment = (posture) => {
876
1297
  ...posture.exposure === void 0 ? [] : [["MEND_EXPOSURE", posture.exposure]],
877
1298
  ...posture.tenancy === void 0 ? [] : [["MEND_TENANCY", posture.tenancy]],
878
1299
  ...gate ? [["MEND_SOURCE_POLICY", "tenant"], ["MEND_CAPTURE_REQUIRE_SIZES", "true"]] : [],
879
- ...posture.exposure === "public" ? [["MEND_URL_BEARERS", "refuse"]] : []
1300
+ ...posture.exposure === "public" ? [["MEND_URL_BEARERS", "refuse"]] : [],
1301
+ // The gate's workspace-ssh item reads where SSH is published apart from the web port; the
1302
+ // container cannot see what its host publishes.
1303
+ ...posture.sshBind === void 0 || posture.sshPort === void 0 ? [] : [["MEND_SSH_PUBLISHED", publishedAddress(posture.sshBind, posture.sshPort)]],
1304
+ ...posture.declared === void 0 || posture.declared.length === 0 ? [] : [["MEND_EXPOSURE_DECLARED", posture.declared.join(",")]]
880
1305
  ];
881
1306
  };
882
1307
  var postureEnvLines = (posture) => [
@@ -902,7 +1327,8 @@ var renderPostureOverlay = (posture) => {
902
1327
  };
903
1328
  var composeOverlays = (posture) => [
904
1329
  ...posture.edgeHost === void 0 ? [] : [EDGE_COMPOSE_FILE],
905
- ...renderPostureOverlay(posture) === void 0 ? [] : [POSTURE_COMPOSE_FILE]
1330
+ ...renderPostureOverlay(posture) === void 0 ? [] : [POSTURE_COMPOSE_FILE],
1331
+ ...runsMirrors(posture.mirrors) ? [MIRRORS_COMPOSE_FILE] : []
906
1332
  ];
907
1333
  var record = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? new Map(Object.entries(value)) : null;
908
1334
  var healthPosture = (body) => {
@@ -927,7 +1353,11 @@ var healthPosture = (body) => {
927
1353
  var declaredPostureLines = (posture) => [
928
1354
  ...posture.edgeHost === void 0 ? [] : [`edge \xB7 ${posture.edgeHost} \xB7 ${EDGE_IMAGE} on 80 and 443 \xB7 Mend's own port on loopback`],
929
1355
  `exposure \xB7 declared ${posture.exposure ?? "private"}${posture.exposure === void 0 ? " \xB7 the default, not set on this install" : ""}`,
930
- `tenancy \xB7 declared ${posture.tenancy ?? "single"}${posture.tenancy === void 0 ? " \xB7 the default, not set on this install" : ""}`
1356
+ `tenancy \xB7 declared ${posture.tenancy ?? "single"}${posture.tenancy === void 0 ? " \xB7 the default, not set on this install" : ""}`,
1357
+ ...posture.sshBind === void 0 || posture.sshPort === void 0 ? [] : [
1358
+ `workspace ssh \xB7 published on ${publishedAddress(posture.sshBind, posture.sshPort)} apart from the web port`
1359
+ ],
1360
+ ...posture.declared === void 0 || posture.declared.length === 0 ? [] : [`stated verified from outside \xB7 ${posture.declared.join(", ")}`]
931
1361
  ];
932
1362
  var observedEdgeLine = (host, observed) => [
933
1363
  `edge \xB7 ${host}`,
@@ -1969,7 +2399,7 @@ var COMMANDS = [
1969
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.",
1970
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.",
1971
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.",
1972
- "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.",
1973
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).",
1974
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."
1975
2405
  ],
@@ -1994,31 +2424,39 @@ var COMMANDS = [
1994
2424
  name: "logs",
1995
2425
  section: "sessions",
1996
2426
  summary: "print a session's recorded terminal output",
1997
- 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
+ ],
1998
2431
  description: [
1999
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.",
2000
- "<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."
2001
2435
  ],
2002
2436
  options: [
2003
2437
  { flag: "--follow, -f", text: "keep printing until the process ends" },
2004
2438
  { flag: "--from <sequence>", text: "start at a record sequence. Default: 0" },
2005
- { 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" }
2006
2445
  ],
2007
- examples: [{ command: "mend logs 3f2a --follow", text: "" }],
2008
2446
  see: ["run", "wait", "service logs"]
2009
2447
  },
2010
2448
  {
2011
2449
  name: "wait",
2012
2450
  section: "sessions",
2013
2451
  summary: "wait for a session's command to end",
2014
- synopsis: ["[session] [--timeout <seconds>] [--process <id>] [--json]"],
2452
+ synopsis: ["[session] [--timeout <duration>] [--process <id>] [--json]"],
2015
2453
  description: [
2016
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.",
2017
- "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.",
2018
2456
  "<session> is the session id, a prefix of it, or the worktree's name. With none, the one live session is taken."
2019
2457
  ],
2020
2458
  options: [
2021
- { 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" },
2022
2460
  {
2023
2461
  flag: "--process <id>",
2024
2462
  text: "wait for this process of the session, by a prefix of its id"
@@ -2219,18 +2657,22 @@ var COMMANDS = [
2219
2657
  section: "services",
2220
2658
  summary: "start and supervise a server in the session's workspace",
2221
2659
  synopsis: [
2222
- "[session] --port <port> [--name <n>] [--udp] [--http|--https] [--wait] [--no-connect] -- <command...>",
2223
- "[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]"
2224
2662
  ],
2225
2663
  description: [
2226
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.",
2227
- "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.",
2228
2666
  "The port is tunnelled to this machine's loopback as soon as it listens, unless --no-connect."
2229
2667
  ],
2230
2668
  options: [
2231
2669
  {
2232
2670
  flag: "--wait",
2233
- 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"
2234
2676
  },
2235
2677
  { flag: "--port <port>", text: "the port the command listens on inside the workspace" },
2236
2678
  { flag: "--name <n>", text: "the service's name. Default: the command" },
@@ -2240,9 +2682,13 @@ var COMMANDS = [
2240
2682
  ],
2241
2683
  examples: [
2242
2684
  { command: "mend service run --port 3000 -- pnpm dev", text: "" },
2243
- { 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
+ }
2244
2690
  ],
2245
- see: ["service init", "service connect", "service logs"]
2691
+ see: ["service init", "service connect", "service logs", "logs"]
2246
2692
  },
2247
2693
  {
2248
2694
  name: "service add",
@@ -2275,9 +2721,13 @@ var COMMANDS = [
2275
2721
  name: "service list",
2276
2722
  section: "services",
2277
2723
  summary: "every live service and its observed state",
2278
- synopsis: [],
2279
- description: ["Name, session, port, whether it listens, and where it is reachable from here."],
2280
- 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"]
2281
2731
  },
2282
2732
  {
2283
2733
  name: "service logs",
@@ -2710,7 +3160,7 @@ var COMMANDS = [
2710
3160
  summary: "how this instance is exposed, as declared and as observed",
2711
3161
  synopsis: [],
2712
3162
  description: [
2713
- "Operator only. States MEND_EXPOSURE as declared (loopback, private or public) and one line per item of the public exposure gate, with how it was established: observed (this server read it), carried (this build contains it, and the server cannot see it in effect), declared (you stated it and the server cannot check it), or open. MEND_EXPOSURE=public refuses to start while an item the server can observe is open. Items it cannot observe say what would verify them; once you have verified core-private or edge-tls from outside, name it in MEND_EXPOSURE_DECLARED. The report is what was observed; it is not a statement that the instance is fit to expose."
3163
+ "Operator only. States MEND_EXPOSURE as declared (loopback, private or public) and one line per item of the public exposure gate, with how it was established: observed (this server read it), carried (this build contains it, and the server cannot see it in effect), declared (you stated it and the server cannot check it), or open. MEND_EXPOSURE=public refuses to start while an item that blocks a start is open: every item the server can observe, and workspace-ssh until you declare it. Items it cannot observe say what would verify them; once you have verified core-private, edge-tls or workspace-ssh (SSH published apart from the web port) from outside, name it in MEND_EXPOSURE_DECLARED, or with mend server setup --declare. The report is what was observed; it is not a statement that the instance is fit to expose."
2714
3164
  ],
2715
3165
  see: ["operator gate", "doctor"]
2716
3166
  },
@@ -2796,13 +3246,14 @@ var COMMANDS = [
2796
3246
  section: "this machine",
2797
3247
  summary: "install or repair the local Mend server",
2798
3248
  synopsis: [
2799
- "[--context <name>] [--version <version|latest>] [--bind <ip>] [--url <origin>] [--origin <origin>...] [--port <n>] [--ssh-port <n>] [--edge <host> | --no-edge] [--exposure <loopback|private|public>] [--tenancy <single|multi>] [--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]"
2800
3250
  ],
2801
3251
  description: [
2802
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.",
2803
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.",
2804
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.",
2805
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.",
2806
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.",
2807
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."
2808
3259
  ],
@@ -2816,6 +3267,10 @@ var COMMANDS = [
2816
3267
  text: "exact Mend server version, or latest; omitted keeps the current pin"
2817
3268
  },
2818
3269
  { flag: "--bind <ip>", text: "published listen address. Default: 127.0.0.1" },
3270
+ {
3271
+ flag: "--ssh-bind <ip>",
3272
+ text: "where workspace SSH is published, when not on --bind; with --edge, Remote-SSH from another machine needs it. Kept across reruns; the --bind address takes it away"
3273
+ },
2819
3274
  { flag: "--url <origin>", text: "advertised browser URL, required with a non-loopback bind" },
2820
3275
  {
2821
3276
  flag: "--origin <origin>",
@@ -2839,6 +3294,39 @@ var COMMANDS = [
2839
3294
  flag: "--tenancy <v>",
2840
3295
  text: "declare single or multi; kept across reruns and upgrades. multi also sets the multi mode gate's variables"
2841
3296
  },
3297
+ {
3298
+ flag: "--declare <item>",
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"
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" },
2842
3330
  {
2843
3331
  flag: "--assets-dir <dir>",
2844
3332
  text: "copy compose.v2.yaml and postgres-init.sh from a release directory; fresh setup requires --version"
@@ -2859,6 +3347,10 @@ var COMMANDS = [
2859
3347
  command: "mend server setup --version 0.25.0 --assets-dir ./release-assets --offline",
2860
3348
  text: "install from local release assets and preloaded images"
2861
3349
  },
3350
+ {
3351
+ command: "mend server setup --edge mend.example.com --ssh-bind 0.0.0.0 --exposure public --declare workspace-ssh",
3352
+ text: "the TLS edge for the browser and the API, and workspace SSH for Remote-SSH from other machines"
3353
+ },
2862
3354
  {
2863
3355
  command: "mend server setup --edge mend.example.com",
2864
3356
  text: "a TLS edge for mend.example.com; its DNS points at this machine and 80 and 443 reach it"
@@ -2866,6 +3358,10 @@ var COMMANDS = [
2866
3358
  {
2867
3359
  command: "mend server setup --exposure public --tenancy multi",
2868
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"
2869
3365
  }
2870
3366
  ],
2871
3367
  see: ["server", "server status", "login", "doctor", "operator exposure"]
@@ -2873,10 +3369,11 @@ var COMMANDS = [
2873
3369
  {
2874
3370
  name: "server status",
2875
3371
  section: "this machine",
2876
- summary: "show the pin, generation, containers, edge and posture",
3372
+ summary: "show the pin, generation, containers, edge, mirrors and posture",
2877
3373
  synopsis: [""],
2878
3374
  description: [
2879
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.",
2880
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."
2881
3378
  ],
2882
3379
  see: ["server logs", "server start", "operator gate", "operator exposure"]
@@ -2895,7 +3392,7 @@ var COMMANDS = [
2895
3392
  {
2896
3393
  name: "server stop",
2897
3394
  section: "this machine",
2898
- summary: "stop Mend, Postgres, Garage and the edge without deleting data",
3395
+ summary: "stop the server's containers without deleting data",
2899
3396
  synopsis: [""],
2900
3397
  description: [
2901
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."
@@ -3424,7 +3921,7 @@ var writeBundle = (files, outPath, now) => {
3424
3921
  entries: entries.map((entry) => ({ path: entry.path, bytes: entry.content.length }))
3425
3922
  };
3426
3923
  };
3427
- var formatBytes = (bytes) => {
3924
+ var formatBytes2 = (bytes) => {
3428
3925
  if (bytes < 1024) return `${bytes} B`;
3429
3926
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} kB`;
3430
3927
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
@@ -3433,8 +3930,8 @@ var NOTICE = "Contains logs and configuration; secrets are redacted, but read it
3433
3930
  var bundleReport = (written) => {
3434
3931
  const width = Math.max(...written.entries.map((entry) => entry.path.length), 0);
3435
3932
  return [
3436
- `${written.path} \xB7 ${formatBytes(written.bytes)}`,
3437
- ...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)}`),
3438
3935
  NOTICE
3439
3936
  ];
3440
3937
  };
@@ -4256,7 +4753,7 @@ var gitAuthorLine = (author) => `${author.name} <${author.email}> \xB7 ${author.
4256
4753
 
4257
4754
  // src/http-client.ts
4258
4755
  import { Agent, setGlobalDispatcher } from "undici";
4259
- var cliDispatcher = (connect2) => new Agent({ allowH2: false, ...connect2 === void 0 ? {} : { connect: connect2 } });
4756
+ var cliDispatcher = (connect3) => new Agent({ allowH2: false, ...connect3 === void 0 ? {} : { connect: connect3 } });
4260
4757
  var useHttp1 = () => {
4261
4758
  setGlobalDispatcher(cliDispatcher());
4262
4759
  };
@@ -4579,7 +5076,7 @@ var fetchedLines = (fetched) => {
4579
5076
  lines.push(`${dim(" switch to it")} git switch ${fetched.branch}`);
4580
5077
  return lines;
4581
5078
  };
4582
- var formatBytes2 = (bytes) => {
5079
+ var formatBytes3 = (bytes) => {
4583
5080
  if (bytes < 1024) return `${bytes} bytes`;
4584
5081
  const units = ["KiB", "MiB", "GiB"];
4585
5082
  let value = bytes / 1024;
@@ -4612,7 +5109,7 @@ var bundleRefusal = (downloaded) => {
4612
5109
  const size = numberField(body, "size");
4613
5110
  const limit = numberField(body, "limit");
4614
5111
  if (downloaded.status === 413 && size !== null && limit !== null) {
4615
- 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`;
4616
5113
  }
4617
5114
  return stringField2(body, "message") ?? `the bundle request answered ${downloaded.status}`;
4618
5115
  };
@@ -5241,13 +5738,13 @@ var renderExposure = (report) => {
5241
5738
  const mark = (item) => marks[item.established];
5242
5739
  const open = report.items.filter((item) => item.established === "open");
5243
5740
  const blocking = open.filter((item) => item.blocksStart).length;
5244
- const unobservable = open.length - blocking;
5741
+ const notBlocking = open.length - blocking;
5245
5742
  return [
5246
5743
  `exposure \xB7 declared ${report.declared} \xB7 ${EXPOSURE_WORDS[report.declared]}`,
5247
5744
  ...report.items.map(
5248
5745
  (item) => `${mark(item)} ${item.id.padEnd(width)} ${item.established.padEnd(8)} ${item.detail}${item.fix === null ? "" : dim4(` \xB7 ${item.fix}`)}`
5249
5746
  ),
5250
- open.length === 0 ? "nothing open \xB7 every item was observed here, is carried by this build, or was declared by the operator" : `${open.length} of ${report.items.length} items open \xB7 ${blocking} this build can observe${blocking === 0 ? "" : "; MEND_EXPOSURE=public refuses to start"} \xB7 ${unobservable} it cannot`
5747
+ open.length === 0 ? "nothing open \xB7 every item was observed here, is carried by this build, or was declared by the operator" : `${open.length} of ${report.items.length} items open \xB7 ${blocking} refuse a public start${blocking === 0 ? "" : "; MEND_EXPOSURE=public refuses to start"} \xB7 ${notBlocking} do not`
5251
5748
  ];
5252
5749
  };
5253
5750
  var renderOrganizations = (rows) => {
@@ -5644,21 +6141,39 @@ var piProfileLines = (scan) => {
5644
6141
 
5645
6142
  // src/run-scripts.ts
5646
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;
5647
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");
5648
6149
  var runArgvIssue = (argv) => {
5649
6150
  if (argv.length > RUN_ARGV_MAX) {
5650
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`;
5651
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;
5652
6161
  for (const [index, word] of argv.entries()) {
5653
6162
  const which = index === 0 ? "the program" : `argument ${index}`;
5654
- if (word === "") {
5655
- 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)`;
5656
6168
  }
5657
- if (word.trim() !== word) {
5658
- const leading = word.trimStart() !== word;
5659
- const edge = leading ? word.charAt(0) : word.charAt(word.length - 1);
5660
- 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`;
5661
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`;
5662
6177
  }
5663
6178
  return null;
5664
6179
  };
@@ -5811,16 +6326,22 @@ var splitArgs = (args, valued, switches) => {
5811
6326
  return { values, on, session };
5812
6327
  };
5813
6328
  var parseLogsArgs = (args) => {
5814
- const split = splitArgs(args, ["--from", "--process"], ["--follow", "-f"]);
6329
+ const split = splitArgs(args, ["--from", "--process", "--service"], ["--follow", "-f"]);
5815
6330
  if ("error" in split) return split;
5816
6331
  const from = split.values.get("--from") ?? "0";
5817
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
+ }
5818
6338
  return {
5819
6339
  args: {
5820
6340
  session: split.session,
5821
6341
  follow: split.on.has("--follow") || split.on.has("-f"),
5822
6342
  from,
5823
- process: split.values.get("--process") ?? null
6343
+ process: process2,
6344
+ service
5824
6345
  }
5825
6346
  };
5826
6347
  };
@@ -5828,21 +6349,149 @@ var parseWaitArgs = (args) => {
5828
6349
  const split = splitArgs(args, ["--timeout", "--process"], ["--json"]);
5829
6350
  if ("error" in split) return split;
5830
6351
  const timeout = split.values.get("--timeout");
5831
- const seconds2 = timeout === void 0 ? null : Number(timeout);
5832
- if (seconds2 !== null && !(Number.isFinite(seconds2) && seconds2 > 0)) {
5833
- return { error: "--timeout takes a number of seconds above 0" };
5834
- }
6352
+ const timeoutMs = timeout === void 0 ? null : parseDuration(timeout);
6353
+ if (timeoutMs === null && timeout !== void 0) return { error: DURATION_ISSUE };
5835
6354
  return {
5836
6355
  args: {
5837
6356
  session: split.session,
5838
6357
  process: split.values.get("--process") ?? null,
5839
- timeoutMs: seconds2 === null ? null : Math.ceil(seconds2 * 1e3),
6358
+ timeoutMs,
5840
6359
  json: split.on.has("--json")
5841
6360
  }
5842
6361
  };
5843
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
+ };
5844
6381
  var WAIT_TIMED_OUT = 124;
5845
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
+ };
5846
6495
 
5847
6496
  // src/secret-files.ts
5848
6497
  import * as path14 from "node:path";
@@ -6148,7 +6797,14 @@ var writeDurable = (file, content, mode = 384) => {
6148
6797
  }
6149
6798
  };
6150
6799
  var fileKeys = ["identity", "config", "env", "compose", "postgresInit"];
6151
- var optionalFileKeys = ["posture", "edge", "caddyfile"];
6800
+ var optionalFileKeys = [
6801
+ "posture",
6802
+ "edge",
6803
+ "caddyfile",
6804
+ "mirrors",
6805
+ "npmMirrorConf",
6806
+ "dockerMirrorGuard"
6807
+ ];
6152
6808
  var fileNames = {
6153
6809
  identity: "identity.env",
6154
6810
  config: "server.json",
@@ -6157,7 +6813,10 @@ var fileNames = {
6157
6813
  postgresInit: "postgres-init.sh",
6158
6814
  posture: "compose.posture.yaml",
6159
6815
  edge: "compose.edge.yaml",
6160
- caddyfile: "Caddyfile"
6816
+ caddyfile: "Caddyfile",
6817
+ mirrors: "compose.mirrors.yaml",
6818
+ npmMirrorConf: "npm-mirror.conf",
6819
+ dockerMirrorGuard: "docker-mirror-guard.sh"
6161
6820
  };
6162
6821
  var fileModes = {
6163
6822
  identity: 384,
@@ -6167,7 +6826,10 @@ var fileModes = {
6167
6826
  postgresInit: 493,
6168
6827
  posture: 384,
6169
6828
  edge: 384,
6170
- caddyfile: 420
6829
+ caddyfile: 420,
6830
+ mirrors: 384,
6831
+ npmMirrorConf: 420,
6832
+ dockerMirrorGuard: 420
6171
6833
  };
6172
6834
  var allFiles = (files) => [...fileKeys, ...optionalFileKeys].map((key) => [key, files[key]]);
6173
6835
  var parseLockOwner = (raw) => {
@@ -6271,6 +6933,9 @@ var readActive = (paths) => {
6271
6933
  const posture = optional("posture");
6272
6934
  const edge = optional("edge");
6273
6935
  const caddyfile = optional("caddyfile");
6936
+ const mirrors = optional("mirrors");
6937
+ const npmMirrorConf = optional("npmMirrorConf");
6938
+ const dockerMirrorGuard = optional("dockerMirrorGuard");
6274
6939
  const files = {
6275
6940
  identity: fs14.readFileSync(path15.join(directory, fileNames.identity), "utf8"),
6276
6941
  config: fs14.readFileSync(path15.join(directory, fileNames.config), "utf8"),
@@ -6279,7 +6944,10 @@ var readActive = (paths) => {
6279
6944
  postgresInit: fs14.readFileSync(path15.join(directory, fileNames.postgresInit), "utf8"),
6280
6945
  ...posture === void 0 ? {} : { posture },
6281
6946
  ...edge === void 0 ? {} : { edge },
6282
- ...caddyfile === void 0 ? {} : { caddyfile }
6947
+ ...caddyfile === void 0 ? {} : { caddyfile },
6948
+ ...mirrors === void 0 ? {} : { mirrors },
6949
+ ...npmMirrorConf === void 0 ? {} : { npmMirrorConf },
6950
+ ...dockerMirrorGuard === void 0 ? {} : { dockerMirrorGuard }
6283
6951
  };
6284
6952
  if (readIdentity(paths) !== files.identity) {
6285
6953
  throw new ServerStoreError(
@@ -6817,6 +7485,7 @@ var SETUP_FLAGS = /* @__PURE__ */ new Set([
6817
7485
  "--context",
6818
7486
  "--version",
6819
7487
  "--bind",
7488
+ "--ssh-bind",
6820
7489
  "--url",
6821
7490
  "--origin",
6822
7491
  "--port",
@@ -6827,7 +7496,29 @@ var SETUP_FLAGS = /* @__PURE__ */ new Set([
6827
7496
  "--edge",
6828
7497
  "--no-edge",
6829
7498
  "--exposure",
6830
- "--tenancy"
7499
+ "--tenancy",
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"
6831
7522
  ]);
6832
7523
  var parseExposure = (value) => {
6833
7524
  if (!isExposure(value))
@@ -6839,6 +7530,21 @@ var parseTenancy = (value) => {
6839
7530
  throw setupError(`--tenancy must be one of ${TENANCIES.join(", ")}, not "${value}".`);
6840
7531
  return value;
6841
7532
  };
7533
+ var parseDeclared = (values) => {
7534
+ if (values.length === 1 && values[0] === "none") return [];
7535
+ return [
7536
+ ...new Set(
7537
+ values.map((value) => {
7538
+ if (!isDeclarableItem(value)) {
7539
+ throw setupError(
7540
+ `--declare takes ${DECLARABLE_ITEMS.join(", ")} or none, not "${value}". Every other gate item is observed by the server, never stated.`
7541
+ );
7542
+ }
7543
+ return value;
7544
+ })
7545
+ )
7546
+ ];
7547
+ };
6842
7548
  var parseEdge = (value) => {
6843
7549
  const host = parseEdgeHost(value);
6844
7550
  if (host === null) {
@@ -6855,7 +7561,7 @@ var parseSetupOptions = (args) => {
6855
7561
  if (flag === void 0) continue;
6856
7562
  if (!flag.startsWith("--")) throw setupError(`Unexpected server setup argument "${flag}".`);
6857
7563
  if (!SETUP_FLAGS.has(flag)) throw setupError(`Unknown server setup option "${flag}".`);
6858
- if (flag === "--offline" || flag === "--no-edge") {
7564
+ if (SWITCHES.has(flag)) {
6859
7565
  if (values.has(flag)) throw setupError(`${flag} may be supplied only once.`);
6860
7566
  values.set(flag, ["true"]);
6861
7567
  continue;
@@ -6863,7 +7569,7 @@ var parseSetupOptions = (args) => {
6863
7569
  const [value, valueIndex] = nextFlagValue(args, index, flag);
6864
7570
  index = valueIndex;
6865
7571
  const previous = values.get(flag) ?? [];
6866
- if (flag !== "--origin" && previous.length > 0) {
7572
+ if (flag !== "--origin" && flag !== "--declare" && previous.length > 0) {
6867
7573
  throw setupError(`${flag} may be supplied only once.`);
6868
7574
  }
6869
7575
  previous.push(value);
@@ -6877,12 +7583,59 @@ var parseSetupOptions = (args) => {
6877
7583
  const edge = flagValue3("--edge");
6878
7584
  const exposure = flagValue3("--exposure");
6879
7585
  const tenancy = flagValue3("--tenancy");
7586
+ const declared = values.get("--declare");
6880
7587
  if (edge !== void 0 && values.has("--no-edge"))
6881
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
+ );
6882
7634
  return {
6883
7635
  context: flagValue3("--context"),
6884
7636
  version: version === void 0 ? void 0 : parseVersion(version),
6885
7637
  bind: flagValue3("--bind"),
7638
+ sshBind: flagValue3("--ssh-bind"),
6886
7639
  url: flagValue3("--url"),
6887
7640
  origins: origins === void 0 ? void 0 : origins,
6888
7641
  appPort: appPort === void 0 ? void 0 : parsePort(appPort, "--port"),
@@ -6893,9 +7646,61 @@ var parseSetupOptions = (args) => {
6893
7646
  edge: edge === void 0 ? void 0 : parseEdge(edge),
6894
7647
  noEdge: values.has("--no-edge"),
6895
7648
  exposure: exposure === void 0 ? void 0 : parseExposure(exposure),
6896
- tenancy: tenancy === void 0 ? void 0 : parseTenancy(tenancy)
7649
+ tenancy: tenancy === void 0 ? void 0 : parseTenancy(tenancy),
7650
+ declared: declared === void 0 ? void 0 : parseDeclared(declared),
7651
+ npmMirror,
7652
+ npmMirrorMaxSize,
7653
+ dockerMirror,
7654
+ dockerMirrorMaxSize,
7655
+ dockerHubUsername,
7656
+ dockerHubTokenStdin,
7657
+ noDockerHubLogin
7658
+ };
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 }
6897
7671
  };
6898
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
+ };
6899
7704
  var parseUrl = (input, label) => {
6900
7705
  try {
6901
7706
  return new URL(input);
@@ -6927,9 +7732,13 @@ var resolveAppUrl = (existing, options, appPort, edgeHost) => {
6927
7732
  }
6928
7733
  return origin;
6929
7734
  }
6930
- const oldLocalUrl = existing === null ? null : `http://localhost:${existing.appPort}`;
6931
- const portChangedOnLocalhost = options.appPort !== void 0 && existing?.appUrl === oldLocalUrl;
6932
- const fallback = portChangedOnLocalhost || existing?.edgeHost !== void 0 ? `http://localhost:${appPort}` : existing?.appUrl ?? `http://localhost:${appPort}`;
7735
+ if (existing?.edgeHost !== void 0) {
7736
+ return parseHttpOrigin(options.url ?? `http://localhost:${appPort}`, "--url");
7737
+ }
7738
+ const saved = existing === null ? null : new URL(existing.appUrl);
7739
+ const savedPointsAtOldPort = saved !== null && existing !== null && options.appPort !== void 0 && saved.protocol === "http:" && saved.port !== "" && Number(saved.port) === existing.appPort;
7740
+ if (saved !== null && savedPointsAtOldPort) saved.port = String(appPort);
7741
+ const fallback = saved === null ? `http://localhost:${appPort}` : saved.origin;
6933
7742
  return parseHttpOrigin(options.url ?? fallback, "--url");
6934
7743
  };
6935
7744
  var checkExposurePair = (bind, appUrl, requireExplicitUrl) => {
@@ -6944,7 +7753,7 @@ var checkExposurePair = (bind, appUrl, requireExplicitUrl) => {
6944
7753
  );
6945
7754
  }
6946
7755
  };
6947
- var checkPosture = (bind, ports, edgeHost, exposure) => {
7756
+ var checkPosture = (bind, sshBind, ports, edgeHost, exposure, declared) => {
6948
7757
  if (edgeHost !== void 0 && [ports.appPort, ports.sshPort].some((p) => p === 80 || p === 443)) {
6949
7758
  throw setupError(
6950
7759
  "With an edge, --port and --ssh-port must not be 80 or 443: the edge publishes both on every interface."
@@ -6965,6 +7774,16 @@ var checkPosture = (bind, ports, edgeHost, exposure) => {
6965
7774
  "--exposure loopback contradicts a non-loopback --bind: the port is published beyond this machine."
6966
7775
  );
6967
7776
  }
7777
+ if (exposure === "loopback" && !isLoopbackBind(sshBind)) {
7778
+ throw setupError(
7779
+ "--exposure loopback contradicts a non-loopback --ssh-bind: the SSH port is published beyond this machine."
7780
+ );
7781
+ }
7782
+ if (exposure === "public" && !isLoopbackBind(sshBind) && !declared.includes("workspace-ssh")) {
7783
+ throw setupError(
7784
+ `--exposure public with workspace SSH published on ${publishedAddress(sshBind, ports.sshPort)} beside the edge needs a statement Mend cannot observe: who reaches that port. Check it from each network that should not reach it, then add --declare workspace-ssh; or publish SSH on loopback.`
7785
+ );
7786
+ }
6968
7787
  if (exposure === "public" && edgeHost === void 0) {
6969
7788
  throw setupError(
6970
7789
  "--exposure public needs the edge: add --edge <host>. Without it nothing sets MEND_TRUSTED_PROXIES or an https origin, and the server refuses to start as public."
@@ -6983,10 +7802,18 @@ var validateExposure = (existing, options) => {
6983
7802
  "--bind must be a literal IPv4 or IPv6 address, such as 127.0.0.1 or 0.0.0.0."
6984
7803
  );
6985
7804
  }
7805
+ const requestedSshBind = options.sshBind ?? existing?.sshBind;
7806
+ if (requestedSshBind !== void 0 && net3.isIP(requestedSshBind) === 0) {
7807
+ throw setupError(
7808
+ "--ssh-bind must be a literal IPv4 or IPv6 address, such as 127.0.0.1 or 0.0.0.0."
7809
+ );
7810
+ }
7811
+ const sshBind = requestedSshBind === bind ? void 0 : requestedSshBind;
6986
7812
  const edgeHost = options.noEdge ? void 0 : options.edge ?? existing?.edgeHost;
6987
7813
  const exposure = options.exposure ?? existing?.exposure;
6988
7814
  const tenancy = options.tenancy ?? existing?.tenancy;
6989
- checkPosture(bind, { appPort, sshPort }, edgeHost, exposure);
7815
+ const declared = options.declared ?? existing?.declared ?? [];
7816
+ checkPosture(bind, sshBind ?? bind, { appPort, sshPort }, edgeHost, exposure, declared);
6990
7817
  const appUrl = resolveAppUrl(existing, options, appPort, edgeHost);
6991
7818
  if (edgeHost === void 0)
6992
7819
  checkExposurePair(bind, appUrl, options.url === void 0 && existing === null);
@@ -6997,13 +7824,15 @@ var validateExposure = (existing, options) => {
6997
7824
  ];
6998
7825
  return {
6999
7826
  bind,
7827
+ ...sshBind === void 0 ? {} : { sshBind },
7000
7828
  appUrl,
7001
7829
  allowedOrigins,
7002
7830
  appPort,
7003
7831
  sshPort,
7004
7832
  ...edgeHost === void 0 ? {} : { edgeHost },
7005
7833
  ...exposure === void 0 ? {} : { exposure },
7006
- ...tenancy === void 0 ? {} : { tenancy }
7834
+ ...tenancy === void 0 ? {} : { tenancy },
7835
+ ...declared.length === 0 ? {} : { declared }
7007
7836
  };
7008
7837
  };
7009
7838
  var parseServerConfig = (raw) => {
@@ -7042,6 +7871,13 @@ var parseServerConfig = (raw) => {
7042
7871
  if (tenancy !== void 0 && !isTenancy(tenancy)) {
7043
7872
  throw setupError(`Server config is corrupt: tenancy must be one of ${TENANCIES.join(", ")}.`);
7044
7873
  }
7874
+ const declaredField = fields.get("declared");
7875
+ if (declaredField !== void 0 && (!Array.isArray(declaredField) || !declaredField.every((item) => typeof item === "string" && isDeclarableItem(item)))) {
7876
+ throw setupError(
7877
+ `Server config is corrupt: declared must be an array of ${DECLARABLE_ITEMS.join(", ")}.`
7878
+ );
7879
+ }
7880
+ const declared = Array.isArray(declaredField) ? declaredField.filter((item) => isDeclarableItem(String(item))) : [];
7045
7881
  const config = {
7046
7882
  schemaVersion: requiredInteger(fields, "schemaVersion"),
7047
7883
  assetContract: requiredString(fields, "assetContract"),
@@ -7051,6 +7887,7 @@ var parseServerConfig = (raw) => {
7051
7887
  dockerSocket: parseDockerSocketPath(requiredString(fields, "dockerSocket")),
7052
7888
  dockerSocketSource,
7053
7889
  bind: requiredString(fields, "bind"),
7890
+ ...fields.has("sshBind") ? { sshBind: requiredString(fields, "sshBind") } : {},
7054
7891
  appUrl: parseHttpOrigin(requiredString(fields, "appUrl"), "Server config appUrl"),
7055
7892
  allowedOrigins: origins.map(
7056
7893
  (origin) => parseHttpOrigin(origin, "Server config allowedOrigins")
@@ -7061,7 +7898,9 @@ var parseServerConfig = (raw) => {
7061
7898
  ...fields.has("bucket") ? { bucket: requiredBucket(fields) } : {},
7062
7899
  ...edgeHost === void 0 ? {} : { edgeHost },
7063
7900
  ...exposure === void 0 ? {} : { exposure },
7064
- ...tenancy === void 0 ? {} : { tenancy }
7901
+ ...tenancy === void 0 ? {} : { tenancy },
7902
+ ...declared.length === 0 ? {} : { declared },
7903
+ ...fields.has("mirrors") ? { mirrors: parseMirrorsField(fields.get("mirrors")) } : {}
7065
7904
  };
7066
7905
  if (config.schemaVersion !== CONFIG_SCHEMA_VERSION || config.assetContract !== ASSET_CONTRACT && !LEGACY_ASSET_CONTRACTS.has(config.assetContract)) {
7067
7906
  throw setupError(
@@ -7076,6 +7915,7 @@ var parseServerConfig = (raw) => {
7076
7915
  context: void 0,
7077
7916
  version: void 0,
7078
7917
  bind: config.bind,
7918
+ sshBind: config.sshBind,
7079
7919
  url: config.appUrl,
7080
7920
  origins: config.allowedOrigins,
7081
7921
  appPort: config.appPort,
@@ -7086,7 +7926,15 @@ var parseServerConfig = (raw) => {
7086
7926
  edge: config.edgeHost,
7087
7927
  noEdge: false,
7088
7928
  exposure: config.exposure,
7089
- tenancy: config.tenancy
7929
+ tenancy: config.tenancy,
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
7090
7938
  });
7091
7939
  return config;
7092
7940
  };
@@ -7117,8 +7965,12 @@ var parseSecrets = (raw) => {
7117
7965
  betterAuthSecret: envValue(fields, "BETTER_AUTH_SECRET"),
7118
7966
  sealantCredentialsKey: envValue(fields, "SEALANT_CREDENTIALS_KEY"),
7119
7967
  sealantServiceKey: envValue(fields, "SEALANT_SERVICE_KEY"),
7120
- 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") } : {}
7121
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
+ }
7122
7974
  if (!/^slt_svc_[0-9a-f]{64}$/.test(secrets.sealantServiceKey)) {
7123
7975
  throw setupError("Server secrets are corrupt: SEALANT_SERVICE_KEY has an invalid value.");
7124
7976
  }
@@ -7423,9 +8275,10 @@ var renderGarage = (secrets, config) => {
7423
8275
  ];
7424
8276
  };
7425
8277
  var namespaceOf = (config) => config.bucket === "garage" ? MEND_DOCKER_NAMESPACE_WITH_GARAGE : MEND_DOCKER_NAMESPACE;
8278
+ var composeHost = (address) => net3.isIP(address) === 6 ? `[${address}]` : address;
7426
8279
  var renderSecrets = (secrets, config) => {
7427
8280
  const sshHost = parseUrl(config.appUrl, "Server config appUrl").hostname.replace(/^\[|\]$/g, "");
7428
- const composeBind = net3.isIP(config.bind) === 6 ? `[${config.bind}]` : config.bind;
8281
+ const composeBind = composeHost(config.bind);
7429
8282
  return [
7430
8283
  `MEND_VERSION=${config.serverVersion}`,
7431
8284
  "MEND_IMAGE_REPOSITORY=ghcr.io/sealant-sh/mend",
@@ -7433,6 +8286,7 @@ var renderSecrets = (secrets, config) => {
7433
8286
  `APP_URL=${config.appUrl}`,
7434
8287
  `MEND_ALLOWED_ORIGINS=${JSON.stringify(config.allowedOrigins)}`,
7435
8288
  `MEND_BIND_HOST=${composeBind}`,
8289
+ ...config.sshBind === void 0 ? [] : [`MEND_SSH_BIND_HOST=${composeHost(config.sshBind)}`],
7436
8290
  `MEND_PORT=${config.appPort}`,
7437
8291
  `MEND_SSH_PORT=${config.sshPort}`,
7438
8292
  ...config.registryPort === void 0 ? [] : [`MEND_REGISTRY_PORT=${config.registryPort}`],
@@ -7443,6 +8297,9 @@ var renderSecrets = (secrets, config) => {
7443
8297
  "MEND_STORE_VOLUME_NAME=mend-store",
7444
8298
  "MEND_CONTROL_VOLUME_NAME=mend-control",
7445
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),
7446
8303
  `DOCKER_SOCKET_PATH=${config.dockerSocket}`,
7447
8304
  ""
7448
8305
  ].join("\n");
@@ -7493,6 +8350,47 @@ var validateOverlays = (config, files) => {
7493
8350
  }
7494
8351
  return edgeImageOf(files.edge);
7495
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
+ };
7496
8394
  var readServerInstallation = (store) => {
7497
8395
  try {
7498
8396
  const generation = storeValue(store.readActive());
@@ -7507,12 +8405,14 @@ var readServerInstallation = (store) => {
7507
8405
  validateComposeAsset(generation.files.compose);
7508
8406
  validatePostgresAsset(generation.files.postgresInit);
7509
8407
  const edgeImage = validateOverlays(config, generation.files);
8408
+ const mirrorImages = validateMirrors(config, secrets, generation.files);
7510
8409
  return {
7511
8410
  _tag: "ok",
7512
8411
  value: {
7513
8412
  config,
7514
8413
  directory: generation.directory,
7515
- ...edgeImage === void 0 ? {} : { edgeImage }
8414
+ ...edgeImage === void 0 ? {} : { edgeImage },
8415
+ ...mirrorImages.length === 0 ? {} : { mirrorImages }
7516
8416
  }
7517
8417
  };
7518
8418
  } catch (cause) {
@@ -7543,7 +8443,10 @@ var readServerInstallationFacts = async (configDir) => {
7543
8443
  overlays: [
7544
8444
  ...generation.files.edge === void 0 ? [] : [{ name: "compose.edge.yaml", content: generation.files.edge }],
7545
8445
  ...generation.files.caddyfile === void 0 ? [] : [{ name: "Caddyfile", content: generation.files.caddyfile }],
7546
- ...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 }]
7547
8450
  ],
7548
8451
  envKeys: envKeyNames(generation.files.env)
7549
8452
  };
@@ -7555,6 +8458,7 @@ var readServerInstallationFacts = async (configDir) => {
7555
8458
  };
7556
8459
  var generationFiles = (config, secrets, assets, identity = renderIdentity(secrets)) => {
7557
8460
  const posture = renderPostureOverlay(config);
8461
+ const mirrors = renderMirrorsOverlay(config.mirrors);
7558
8462
  return {
7559
8463
  identity,
7560
8464
  config: `${JSON.stringify(config, null, 2)}
@@ -7562,7 +8466,10 @@ var generationFiles = (config, secrets, assets, identity = renderIdentity(secret
7562
8466
  env: renderSecrets(secrets, config),
7563
8467
  ...assets,
7564
8468
  ...posture === void 0 ? {} : { posture },
7565
- ...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 }
7566
8473
  };
7567
8474
  };
7568
8475
  var persistSetup = (store, config, secrets, assets) => storeValue(store.prepare(generationFiles(config, secrets, assets)));
@@ -7632,6 +8539,13 @@ var resolveDockerSocket = (runtime, options, existing, selectedContext) => {
7632
8539
  dockerSocketSource: override === void 0 ? "detected" : "override"
7633
8540
  };
7634
8541
  };
8542
+ var checkSshBindAsset = (config, compose) => {
8543
+ if (config.sshBind !== void 0 && !compose.includes("MEND_SSH_BIND_HOST")) {
8544
+ throw setupError(
8545
+ `Mend ${config.serverVersion}'s ${COMPOSE_ASSET} publishes workspace SSH on --bind and cannot honour --ssh-bind ${config.sshBind}. Use a release that supports --ssh-bind, or pass --ssh-bind with the --bind address.`
8546
+ );
8547
+ }
8548
+ };
7635
8549
  var resolveAssets = async (runtime, serverVersion, existing, store, options) => {
7636
8550
  if (options.assetsDir !== void 0) {
7637
8551
  try {
@@ -7724,6 +8638,17 @@ var checkLocalImages = async (runtime, installation, policy = "local") => {
7724
8638
  if (edge.status !== 0)
7725
8639
  throw commandFailure(`Preload ${installation.edgeImage} before continuing`, edge);
7726
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
+ }
7727
8652
  };
7728
8653
  var checkComposeImages = async (runtime, installation) => {
7729
8654
  const output = await runtime.run(
@@ -7736,11 +8661,12 @@ var checkComposeImages = async (runtime, installation) => {
7736
8661
  `ghcr.io/sealant-sh/mend:${installation.config.serverVersion}`,
7737
8662
  "postgres:17-alpine",
7738
8663
  ...installation.config.bucket === "garage" ? [GARAGE_IMAGE] : [],
7739
- ...installation.edgeImage === void 0 ? [] : [installation.edgeImage]
8664
+ ...installation.edgeImage === void 0 ? [] : [installation.edgeImage],
8665
+ ...installation.mirrorImages ?? []
7740
8666
  ].toSorted();
7741
8667
  if (images.join("\n") !== expected.join("\n")) {
7742
8668
  throw setupError(
7743
- `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(", ")}`}.`
7744
8670
  );
7745
8671
  }
7746
8672
  };
@@ -7787,8 +8713,16 @@ var initGarage = async (runtime, installation, secrets) => {
7787
8713
  var startingNotice = (runtime, version) => runtime.writeLine(
7788
8714
  `Starting Mend ${version} containers; Docker waits up to ${serverProcessDeadlines.composeWaitSeconds}s for them to report healthy`
7789
8715
  );
7790
- var removeStrayEdge = async (runtime, installation) => {
7791
- 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) => {
7792
8726
  const context = installation.config.dockerContext;
7793
8727
  const listed = await runtime.run("docker", [
7794
8728
  "--context",
@@ -7799,13 +8733,13 @@ var removeStrayEdge = async (runtime, installation) => {
7799
8733
  "--filter",
7800
8734
  "label=com.docker.compose.project=mend",
7801
8735
  "--filter",
7802
- "label=com.docker.compose.service=edge",
8736
+ `label=com.docker.compose.service=${stray.service}`,
7803
8737
  "--format",
7804
8738
  '{{.Names}} {{.Label "com.docker.compose.project.working_dir"}}'
7805
8739
  ]);
7806
8740
  if (listed.status !== 0 || listed.error !== void 0) {
7807
8741
  runtime.writeLine(
7808
- `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.`
7809
8743
  );
7810
8744
  return;
7811
8745
  }
@@ -7821,9 +8755,10 @@ var removeStrayEdge = async (runtime, installation) => {
7821
8755
  const [name = "", workingDir = ""] = line.split(" ");
7822
8756
  return { name, workingDir };
7823
8757
  }).filter((row) => row.name !== "");
8758
+ const label = stray.label.charAt(0).toUpperCase() + stray.label.slice(1);
7824
8759
  for (const row of rows.filter((candidate) => !own(candidate.workingDir))) {
7825
8760
  runtime.writeLine(
7826
- `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.`
7827
8762
  );
7828
8763
  }
7829
8764
  const names = rows.filter((row) => own(row.workingDir)).map((row) => row.name);
@@ -7838,16 +8773,16 @@ var removeStrayEdge = async (runtime, installation) => {
7838
8773
  ]);
7839
8774
  if (removed.status !== 0 || removed.error !== void 0) {
7840
8775
  runtime.writeLine(
7841
- `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(" ")}`
7842
8777
  );
7843
8778
  return;
7844
8779
  }
7845
8780
  runtime.writeLine(
7846
- `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.`
7847
8782
  );
7848
8783
  };
7849
8784
  var startCompose = async (runtime, installation) => {
7850
- await removeStrayEdge(runtime, installation);
8785
+ await removeStrayServices(runtime, installation);
7851
8786
  startingNotice(runtime, installation.config.serverVersion);
7852
8787
  const compose = await runtime.run(
7853
8788
  "docker",
@@ -7865,7 +8800,70 @@ var startCompose = async (runtime, installation) => {
7865
8800
  );
7866
8801
  if (compose.status !== 0) throw commandFailure("Mend containers did not start", compose);
7867
8802
  };
7868
- 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.`;
8803
+ var SSH_PROBE_BOUND_MS = 1e4;
8804
+ var sshPublicationLine = async (runtime, config) => {
8805
+ if (config.sshBind === void 0 || isLoopbackBind(config.sshBind)) return null;
8806
+ const published = publishedAddress(config.sshBind, config.sshPort);
8807
+ if (runtime.probeSsh === void 0) return null;
8808
+ const boundMs = runtime.sshProbeBoundMs ?? SSH_PROBE_BOUND_MS;
8809
+ let bound;
8810
+ const probes = await Promise.race([
8811
+ runtime.probeSsh(config.sshBind, config.sshPort),
8812
+ new Promise((resolve8) => {
8813
+ bound = setTimeout(() => resolve8(null), boundMs);
8814
+ })
8815
+ ]).finally(() => clearTimeout(bound));
8816
+ if (probes === null) {
8817
+ return `Workspace SSH is published on ${published}. This machine's look at it did not finish within ${boundMs / 1e3} s. Who reaches it is up to the network and its firewall; mend operator exposure reports it as workspace-ssh.`;
8818
+ }
8819
+ const observed = probes.length === 0 ? "this machine has no address to try it at" : probes.map(
8820
+ (probe) => probe.banner === null ? `${publishedAddress(probe.address, config.sshPort)} did not answer` : `${publishedAddress(probe.address, config.sshPort)} answers (${probe.banner})`
8821
+ ).join(", ");
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.`;
8823
+ };
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
+ };
7869
8867
  var setupServer = async (args, runtime, store) => {
7870
8868
  if (runtime.platform !== "linux" && runtime.platform !== "darwin") {
7871
8869
  throw setupError(`mend server setup supports Linux and macOS, not ${runtime.platform}.`);
@@ -7891,6 +8889,7 @@ var setupServer = async (args, runtime, store) => {
7891
8889
  const operatingSystem = await checkDocker(runtime, selectedContext.name);
7892
8890
  runtime.writeLine(`Using Docker context "${selectedContext.name}" (${selectedContext.endpoint})`);
7893
8891
  const serverVersion = await resolveServerVersion(runtime, options, existing?.config ?? null);
8892
+ const mirrors = resolveMirrors(existing?.config.mirrors, options);
7894
8893
  const configWithoutBucket = {
7895
8894
  schemaVersion: CONFIG_SCHEMA_VERSION,
7896
8895
  assetContract: ASSET_CONTRACT,
@@ -7901,9 +8900,12 @@ var setupServer = async (args, runtime, store) => {
7901
8900
  ...selectedContext,
7902
8901
  operatingSystem
7903
8902
  }),
7904
- ...validateExposure(existing?.config ?? null, options)
8903
+ ...validateExposure(existing?.config ?? null, options),
8904
+ mirrors
7905
8905
  };
8906
+ const dockerHubToken = await resolveDockerHubToken(runtime, options, mirrors, store);
7906
8907
  const assets = await resolveAssets(runtime, serverVersion, existing, store, options);
8908
+ checkSshBindAsset(configWithoutBucket, assets.compose);
7907
8909
  const bucket = composeBucket(assets.compose);
7908
8910
  if (bucket !== void 0 && runtime.dockerDaemonFacts !== void 0) {
7909
8911
  const info = await runtime.run("docker", [
@@ -7927,12 +8929,16 @@ var setupServer = async (args, runtime, store) => {
7927
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.`
7928
8930
  );
7929
8931
  }
7930
- const secrets = savedSecrets ?? createSecrets(runtime);
8932
+ const secrets = {
8933
+ ...savedSecrets ?? createSecrets(runtime),
8934
+ ...dockerHubToken === void 0 ? {} : { dockerHubToken }
8935
+ };
7931
8936
  const generation = persistSetup(store, config, secrets, assets);
7932
8937
  const installation = {
7933
8938
  directory: generation.directory,
7934
8939
  config,
7935
- ...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) }
7936
8942
  };
7937
8943
  await checkComposeImages(runtime, installation);
7938
8944
  const ownership2 = await claimServerDockerVolumes(runtime, {
@@ -7954,11 +8960,14 @@ var setupServer = async (args, runtime, store) => {
7954
8960
  runtime.writeLine(reachableLine(config));
7955
8961
  const edgeStarted = edgeStartedLine(config);
7956
8962
  if (edgeStarted !== null) runtime.writeLine(edgeStarted);
8963
+ const sshPublication = await sshPublicationLine(runtime, config);
8964
+ if (sshPublication !== null) runtime.writeLine(sshPublication);
7957
8965
  if (existing?.config.edgeHost !== void 0 && config.edgeHost === void 0) {
7958
8966
  runtime.writeLine(
7959
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`
7960
8968
  );
7961
8969
  }
8970
+ for (const line of mirrorsChangedLines(existing?.config.mirrors, config)) runtime.writeLine(line);
7962
8971
  runtime.writeLine(
7963
8972
  `Open ${config.appUrl}, create the first account, then run: mend login --url ${config.appUrl}`
7964
8973
  );
@@ -8023,7 +9032,7 @@ var interruptionNotice = (runtime) => runtime.writeLine(
8023
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."
8024
9033
  );
8025
9034
  var startInstallation = async (runtime, installation, secrets) => {
8026
- await removeStrayEdge(runtime, installation);
9035
+ await removeStrayServices(runtime, installation);
8027
9036
  startingNotice(runtime, installation.config.serverVersion);
8028
9037
  await composeCommand(runtime, installation, [
8029
9038
  "up",
@@ -8071,7 +9080,7 @@ var parseUpgradeOptions = (args) => {
8071
9080
  );
8072
9081
  return { ...options, fromPreview, keepBackups };
8073
9082
  };
8074
- var formatBytes3 = (bytes) => {
9083
+ var formatBytes4 = (bytes) => {
8075
9084
  if (bytes < 1024) return `${bytes} B`;
8076
9085
  const units = ["KiB", "MiB", "GiB", "TiB"];
8077
9086
  let value = bytes / 1024;
@@ -8114,11 +9123,11 @@ var pruneUpgradeBackups = (runtime, store, backup, keep) => {
8114
9123
  const { removed, kept, held, failed, unsynced } = pruned.value;
8115
9124
  for (const entry of removed)
8116
9125
  runtime.writeLine(
8117
- `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" : ""}`
8118
9127
  );
8119
9128
  const freed = removed.reduce((total, entry) => total + entry.bytes, 0);
8120
9129
  runtime.writeLine(
8121
- `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})`
8122
9131
  );
8123
9132
  for (const entry of failed)
8124
9133
  runtime.writeLine(`Could not remove upgrade backup ${entry.directory}: ${entry.message}`);
@@ -8276,6 +9285,7 @@ var upgradeServer = async (args, runtime, store, existing) => {
8276
9285
  const previous = storeValue(store.readActive());
8277
9286
  if (previous === null) throw setupError("The active server generation is missing.");
8278
9287
  const assets = await resolveAssets(runtime, version, existing, store, options);
9288
+ checkSshBindAsset({ ...existing.config, serverVersion: version }, assets.compose);
8279
9289
  const {
8280
9290
  registryPort: _legacyRegistryPort,
8281
9291
  bucket: _previousBucket,
@@ -8286,14 +9296,23 @@ var upgradeServer = async (args, runtime, store, existing) => {
8286
9296
  ...carried,
8287
9297
  assetContract: ASSET_CONTRACT,
8288
9298
  serverVersion: version,
8289
- ...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 }
8290
9307
  };
8291
- const secrets = parseSecrets(previous.files.identity);
8292
9308
  const files = generationFiles(config, secrets, assets, previous.files.identity);
8293
9309
  const parsed = parseServerConfig(files.config);
8294
9310
  if (renderSecrets(parseSecrets(files.env), parsed) !== files.env)
8295
9311
  throw setupError("Invalid upgrade configuration.");
8296
- 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
+ };
8297
9316
  await checkLocalImages(
8298
9317
  runtime,
8299
9318
  { config, ...edgeImage },
@@ -8378,6 +9397,7 @@ var upgradeServer = async (args, runtime, store, existing) => {
8378
9397
  );
8379
9398
  }
8380
9399
  runtime.writeLine(`Upgraded to ${version}. Retained database backup: ${backup.directory}`);
9400
+ for (const line of mirrorsChangedLines(existing.config.mirrors, config)) runtime.writeLine(line);
8381
9401
  pruneUpgradeBackups(runtime, store, backup, options.keepBackups);
8382
9402
  };
8383
9403
  var observeEdgeCertificate = async (runtime, installation, host) => {
@@ -8481,6 +9501,75 @@ var exposureReportOf = (value) => {
8481
9501
  const parsed = items.flatMap(exposureItemOf);
8482
9502
  return parsed.length === items.length && parsed.length > 0 ? { declared, items: parsed } : null;
8483
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
+ };
8484
9573
  var serverStatus = async (runtime, installation) => {
8485
9574
  const { config } = installation;
8486
9575
  runtime.writeLine(`Pinned Mend ${config.serverVersion} at ${config.appUrl}`);
@@ -8504,6 +9593,8 @@ var serverStatus = async (runtime, installation) => {
8504
9593
  })
8505
9594
  );
8506
9595
  }
9596
+ for (const line of await mirrorStatusLines(runtime, installation, runningServices))
9597
+ runtime.writeLine(line);
8507
9598
  if (!runningServices.includes("mend")) {
8508
9599
  runtime.writeLine("Mend is stopped. No health claim was made.");
8509
9600
  return;
@@ -8572,7 +9663,10 @@ var manageServer = async (command, args, runtime, store) => {
8572
9663
  "Mend",
8573
9664
  "Postgres",
8574
9665
  ...installation.config.bucket === "garage" ? ["Garage"] : [],
8575
- ...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
+ )
8576
9670
  ];
8577
9671
  runtime.writeLine(
8578
9672
  `${stopped.slice(0, -1).join(", ")} and ${stopped.at(-1)} stopped. Volumes, configuration and workspace containers are retained.`
@@ -8615,9 +9709,51 @@ var nodeServerRuntime = () => {
8615
9709
  writeLine: (line) => process.stdout.write(`${redactCredentials(line)}
8616
9710
  `),
8617
9711
  dockerDaemonFacts: hostDockerDaemonFacts,
8618
- readLogin: (configDir) => savedLogin(configDir, environment)
9712
+ readLogin: (configDir) => savedLogin(configDir, environment),
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
+ }
8619
9720
  };
8620
9721
  };
9722
+ var sshBannerAt = async (host, port, timeoutMs) => {
9723
+ const socket = net3.connect({ host, port });
9724
+ let deadline;
9725
+ try {
9726
+ return await Promise.race([
9727
+ new Promise((resolve8) => {
9728
+ socket.once(
9729
+ "data",
9730
+ (bytes) => resolve8(bytes.toString("utf8").split(/\r?\n/)[0]?.trim() ?? "")
9731
+ );
9732
+ socket.once("end", () => resolve8(null));
9733
+ socket.once("close", () => resolve8(null));
9734
+ socket.once("error", () => resolve8(null));
9735
+ }),
9736
+ new Promise((resolve8) => {
9737
+ deadline = setTimeout(() => resolve8(null), timeoutMs);
9738
+ })
9739
+ ]);
9740
+ } finally {
9741
+ clearTimeout(deadline);
9742
+ socket.destroy();
9743
+ }
9744
+ };
9745
+ var probeSshFromHere = async (bind, port) => {
9746
+ const unspecified = bind === "0.0.0.0" || bind === "::";
9747
+ const addresses = unspecified ? Object.values(os10.networkInterfaces()).flatMap((entries) => entries ?? []).filter(
9748
+ (entry) => !entry.internal && (entry.family === "IPv4" || bind === "::" && !entry.address.startsWith("fe80"))
9749
+ ).map((entry) => entry.address).slice(0, 4) : [bind];
9750
+ return Promise.all(
9751
+ addresses.map(async (address) => ({
9752
+ address,
9753
+ banner: await sshBannerAt(address, port, 3e3)
9754
+ }))
9755
+ );
9756
+ };
8621
9757
  var savedLogin = (configDir, environment) => {
8622
9758
  let saved = null;
8623
9759
  try {
@@ -9782,6 +10918,7 @@ var describeUninstall = async (runtime, scope) => {
9782
10918
  appUrl: read.value.config.appUrl,
9783
10919
  dockerContext: read.value.config.dockerContext,
9784
10920
  edgeHost: read.value.config.edgeHost ?? null,
10921
+ mirrors: mirrorServices(read.value.config.mirrors),
9785
10922
  generations: countEntries(path20.join(configDir, "generations")),
9786
10923
  backups: countEntries(path20.join(configDir, "backups"))
9787
10924
  };
@@ -9811,8 +10948,12 @@ var planLines = (plan, configDir) => {
9811
10948
  `server Mend ${version}${appUrl === "" ? "" : ` at ${appUrl}`}${dockerContext === "" ? "" : ` \xB7 docker context ${dockerContext}`}`
9812
10949
  );
9813
10950
  if (dockerContext !== "") {
9814
- const edge = edgeHost === null ? [] : ["edge"];
9815
- 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
+ ];
9816
10957
  lines.push(
9817
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}`}`
9818
10959
  );
@@ -9871,7 +11012,9 @@ var removeServer = async (runtime, failures, leftovers) => {
9871
11012
  `docker compose down failed: ${(down.error ?? down.stderr.trim()) || "no output"}. Containers and files are retained; fix Docker and run mend uninstall again.`
9872
11013
  );
9873
11014
  }
9874
- 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
+ );
9875
11018
  const namespace = installation.config.bucket === "garage" ? MEND_DOCKER_NAMESPACE_WITH_GARAGE : MEND_DOCKER_NAMESPACE;
9876
11019
  const dataVolumes = [namespace.store, ...secondaryVolumesOf(namespace)];
9877
11020
  const identity = store.readIdentity();
@@ -10844,7 +11987,7 @@ var attachTty = async (config, sessionId, harness, from, processId, options) =>
10844
11987
  process.stdout.write("\x07");
10845
11988
  }
10846
11989
  };
10847
- const connect2 = async () => {
11990
+ const connect3 = async () => {
10848
11991
  let url;
10849
11992
  try {
10850
11993
  url = await socketUrl(
@@ -10913,7 +12056,7 @@ ${dim7(`read-only \xB7 ${detachKeyEnabled ? "Ctrl+] or " : ""}Ctrl+C detaches`)}
10913
12056
  `
10914
12057
  );
10915
12058
  }
10916
- const connected = await connect2();
12059
+ const connected = await connect3();
10917
12060
  stopConnecting();
10918
12061
  if (connected === "unavailable") return "unavailable";
10919
12062
  if (connected === "aborted") return abortReason ?? "interrupted";
@@ -11320,8 +12463,9 @@ var printService = (config, service) => {
11320
12463
  const status = service.status === "reachable" ? green7(service.status) : amber2(service.status);
11321
12464
  const port = `:${service.workspacePort ?? "?"}${service.protocol === "udp" ? "/udp" : ""}`;
11322
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)}`;
11323
12467
  say7(
11324
- `${(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}`)}`
11325
12469
  );
11326
12470
  printServiceAccess(config, service);
11327
12471
  printWorkspaceTtlFailure(service);
@@ -11364,8 +12508,30 @@ var serviceAdd = async (config, args) => {
11364
12508
  say7(dim7(` nothing answered on :${port} yet \u2014 the URL goes live when something listens`));
11365
12509
  }
11366
12510
  };
11367
- 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
+ }
11368
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
+ }
11369
12535
  if (services.length === 0) {
11370
12536
  say7(dim7("no live services \u2014 mend service add <port> adopts a listening one"));
11371
12537
  return;
@@ -11404,48 +12570,184 @@ var autoConnect = async (config, service) => {
11404
12570
  say7(dim7(" remote server \u2014 tunneling the port here \xB7 Ctrl-C stops the tunnel, not the Service"));
11405
12571
  await tunnelServices(config, [service], null);
11406
12572
  };
11407
- var SERVICE_WAIT_MS = (() => {
12573
+ var SERVICE_WAIT_DEFAULT_MS = (() => {
11408
12574
  const configured = Number(process.env["MEND_SERVICE_WAIT_MS"]);
11409
- return Number.isFinite(configured) && configured > 0 ? configured : 9e4;
12575
+ return Number.isFinite(configured) && configured > 0 ? configured : 10 * 6e4;
11410
12576
  })();
11411
- var withinServiceWait = async (work, deadline) => {
11412
- 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);
11413
12598
  if (bounded.done) return bounded.value;
11414
- process.stderr.write(
11415
- `mend: no answer within ${Math.round(SERVICE_WAIT_MS / 1e3)} s \xB7 the Service may still be starting \xB7 mend service list
11416
- `
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
+ )
11417
12650
  );
11418
- return exitFlushed(WAIT_TIMED_OUT);
11419
- };
11420
- var failUnlessAnswered = (config, service, wait) => {
11421
- if (!wait) return;
11422
- const name = service.label ?? service.id.slice(0, 8);
11423
- if (service.status !== "reachable") {
11424
- fail7(
11425
- `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`
11426
12668
  );
11427
- }
11428
- if (willAutoConnect(config, service, false)) {
11429
- 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
+ );
11430
12732
  }
11431
12733
  };
11432
12734
  var serviceRun = async (config, args) => {
11433
12735
  const dashdash = args.indexOf("--");
11434
12736
  const usage2 = usageOf("service run");
11435
- const wait = (dashdash === -1 ? args : args.slice(0, dashdash)).includes("--wait");
11436
- const deadline = wait ? clock.now() + SERVICE_WAIT_MS : null;
12737
+ const head = dashdash === -1 ? args : args.slice(0, dashdash);
12738
+ const wait = serviceWaitOf(head);
11437
12739
  if (dashdash === -1) {
11438
- const positionals = args.filter((a) => !a.startsWith("--"));
12740
+ const positionals = serviceRunPositionals(head);
11439
12741
  const name2 = positionals.at(-1);
11440
12742
  if (name2 === void 0) return fail7(usage2);
11441
12743
  const prefix2 = positionals.length > 1 ? positionals[0] : void 0;
11442
12744
  const session2 = await withinServiceWait(
11443
12745
  resolveLiveSession(config, prefix2, "service run"),
11444
- deadline
12746
+ wait
11445
12747
  );
11446
12748
  const recipes = await withinServiceWait(
11447
12749
  api(config, "GET", `/sessions/${session2.id}/recipes`),
11448
- deadline
12750
+ wait
11449
12751
  );
11450
12752
  const recipe = recipes.find((entry) => entry.name === name2);
11451
12753
  if (recipe === void 0) {
@@ -11454,34 +12756,43 @@ var serviceRun = async (config, args) => {
11454
12756
  recipes.length === 0 ? `no mend.toml recipes in this worktree \u2014 declare [service.${name2}] first` : `no recipe named "${name2}" \u2014 declared: ${known}`
11455
12757
  );
11456
12758
  }
11457
- if (wait && recipe.protocol === "udp") {
11458
- return fail7(`--wait needs a TCP port: ${recipe.name} is UDP, which has no probe`);
11459
- }
11460
- if (wait && recipe.command === null) {
11461
- return fail7(
11462
- `--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
11463
12779
  );
11464
12780
  }
11465
12781
  const service2 = await withSpinner(
11466
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`,
11467
- withinServiceWait(
11468
- mutateService(config, "POST", `/sessions/${session2.id}/services/recipe`, {
11469
- name: recipe.name
11470
- }),
11471
- deadline
11472
- )
12783
+ mutateService(config, "POST", `/sessions/${session2.id}/services/recipe`, {
12784
+ name: recipe.name
12785
+ })
11473
12786
  );
11474
- const tunneling2 = willAutoConnect(config, service2, args.includes("--no-connect") || wait);
12787
+ const tunneling2 = willAutoConnect(config, service2, args.includes("--no-connect"));
11475
12788
  say7(`${green7("\u2713")} Service ${service2.label ?? ""} \xB7 ${service2.status}`);
11476
12789
  printServiceEndpoint(config, service2, tunneling2);
11477
12790
  say7(dim7(` logs: mend service logs ${service2.label ?? service2.id.slice(0, 8)}`));
11478
- failUnlessAnswered(config, service2, wait);
11479
12791
  if (tunneling2) await autoConnect(config, service2);
11480
12792
  return;
11481
12793
  }
11482
12794
  const argv = args.slice(dashdash + 1);
11483
12795
  if (argv.length === 0) return fail7(usage2);
11484
- const head = args.slice(0, dashdash);
11485
12796
  const portFlag = head.indexOf("--port");
11486
12797
  const port = portFlag === -1 ? Number.NaN : Number(head[portFlag + 1]);
11487
12798
  if (!Number.isInteger(port) || port < 1 || port > 65535) return fail7(usage2);
@@ -11493,34 +12804,33 @@ var serviceRun = async (config, args) => {
11493
12804
  if (http && https) return fail7(usage2);
11494
12805
  const browserScheme = https ? "https" : http ? "http" : null;
11495
12806
  if (protocol === "udp" && browserScheme !== null) return fail7(usage2);
11496
- if (protocol === "udp" && wait) {
12807
+ if (protocol === "udp" && wait !== null) {
11497
12808
  return fail7("--wait needs a TCP port: UDP has no probe, so nothing could be waited for");
11498
12809
  }
11499
- const prefix = head.find(
11500
- (a, i) => !a.startsWith("--") && (portFlag === -1 || i !== portFlag + 1) && (nameFlag === -1 || i !== nameFlag + 1)
11501
- );
11502
- const session = await withinServiceWait(
11503
- resolveLiveSession(config, prefix, "service run"),
11504
- deadline
11505
- );
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
+ }
11506
12826
  const service = await withSpinner(
11507
12827
  protocol === "udp" ? `starting ${name ?? argv[0]} (udp :${port})\u2026` : `starting ${name ?? argv[0]} \u2014 waiting for :${port} to answer\u2026`,
11508
- withinServiceWait(
11509
- mutateService(config, "POST", `/sessions/${session.id}/services/run`, {
11510
- argv,
11511
- port,
11512
- name,
11513
- protocol,
11514
- browserScheme
11515
- }),
11516
- deadline
11517
- )
12828
+ mutateService(config, "POST", `/sessions/${session.id}/services/run`, body)
11518
12829
  );
11519
- const tunneling = willAutoConnect(config, service, head.includes("--no-connect") || wait);
12830
+ const tunneling = willAutoConnect(config, service, head.includes("--no-connect"));
11520
12831
  say7(`${green7("\u2713")} Service ${service.label ?? ""} \xB7 ${service.status}`);
11521
12832
  printServiceEndpoint(config, service, tunneling);
11522
12833
  say7(dim7(` logs: mend service logs ${service.label ?? service.id.slice(0, 8)}`));
11523
- failUnlessAnswered(config, service, wait);
11524
12834
  if (tunneling) await autoConnect(config, service);
11525
12835
  };
11526
12836
  var serviceLogs = async (config, args) => {
@@ -11748,8 +13058,9 @@ var serviceCommand = async (config, args) => {
11748
13058
  case "connect":
11749
13059
  return serviceConnect(config, rest);
11750
13060
  case "list":
13061
+ return serviceList(config, rest);
11751
13062
  case void 0:
11752
- return serviceList(config);
13063
+ return serviceList(config, []);
11753
13064
  case "logs":
11754
13065
  return serviceLogs(config, rest);
11755
13066
  case "restart":
@@ -12882,7 +14193,7 @@ var printJson = (value) => {
12882
14193
  `);
12883
14194
  };
12884
14195
  var outputOnTerminal = false;
12885
- var stdoutErrorsHandled = false;
14196
+ var recordedOutput = false;
12886
14197
  var outputStopped = false;
12887
14198
  var restoreTerminal = () => {
12888
14199
  if (!outputOnTerminal || process.stdout.isTTY !== true) return;
@@ -12890,13 +14201,17 @@ var restoreTerminal = () => {
12890
14201
  process.stdout.write(`${TERMINAL_MODES_RESET}\x1B[0m`);
12891
14202
  };
12892
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);
12893
14211
  var writeOutput = (bytes) => {
12894
14212
  if (outputStopped) return Promise.reject(new Error("stopped by a signal"));
12895
14213
  if (process.stdout.isTTY === true) outputOnTerminal = true;
12896
- if (!stdoutErrorsHandled) {
12897
- stdoutErrorsHandled = true;
12898
- process.stdout.on("error", () => void 0);
12899
- }
14214
+ recordedOutput = true;
12900
14215
  return new Promise((resolve8, reject) => {
12901
14216
  process.stdout.write(bytes, (error) => {
12902
14217
  if (error === null || error === void 0) resolve8();
@@ -13076,26 +14391,57 @@ var resolveAnySession = async (config, word) => {
13076
14391
  if ("error" in picked) return fail7(picked.error);
13077
14392
  return picked.session;
13078
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
+ };
13079
14404
  var logsCommand = async (config, args) => {
13080
14405
  chromeToStderr();
13081
14406
  const parsed = parseLogsArgs(args);
13082
14407
  if ("error" in parsed) return fail7(`${parsed.error} \xB7 ${usageOf("logs")}`);
13083
- const { session: word, follow, from, process: processPrefix } = parsed.args;
13084
- const session = word === null ? await resolveLiveSession(config, void 0, "logs") : await resolveAnySession(config, word);
13085
- const detail = await api(config, "GET", `/sessions/${session.id}`);
13086
- const picked = pickProcess(detail, processPrefix);
13087
- if ("error" in picked) return fail7(`session ${session.id.slice(0, 8)} \xB7 ${picked.error}`);
13088
- const target = picked.process;
13089
- if (target.id === void 0 || target.sealantSessionId === null) {
13090
- return fail7(
13091
- `session ${session.id.slice(0, 8)} \xB7 this process has no recorded terminal (an adopted port, or a server older than process ids)`
13092
- );
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
+ }
13093
14439
  }
13094
14440
  stopOutputOnSignal(
13095
- 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"
13096
14442
  );
13097
14443
  try {
13098
- const last = await writeProcessLogs(config, target.id, from, follow);
14444
+ const last = await writeProcessLogs(config, target.processId, from, follow);
13099
14445
  restoreTerminal();
13100
14446
  if (follow) say7(dim7(`
13101
14447
  record ended \xB7 ${last.status} \xB7 next sequence ${last.next}`));
@@ -13112,7 +14458,7 @@ var waitCommand = async (config, args) => {
13112
14458
  if ("error" in parsed) return fail7(`${parsed.error} \xB7 ${usageOf("wait")}`);
13113
14459
  const { session: word, timeoutMs, json: json2, process: processPrefix } = parsed.args;
13114
14460
  const deadline = timeoutMs === null ? null : clock.now() + timeoutMs;
13115
- const after = `${Math.round((timeoutMs ?? 0) / 1e3)} s`;
14461
+ const after = durationLine(timeoutMs ?? 0);
13116
14462
  const timedOut = async (session2, processId2, last) => {
13117
14463
  say7(
13118
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`
@@ -13674,7 +15020,7 @@ var dashboard = async (config, options = {}) => {
13674
15020
  );
13675
15021
  process.exit(rerun.status ?? 0);
13676
15022
  }
13677
- const { runDashboard } = await import("./dashboard-CEJN6IOO.js");
15023
+ const { runDashboard } = await import("./dashboard-RAZDVFR7.js");
13678
15024
  const agentShare = await startShareIfBridge(config);
13679
15025
  const tunnels = options.noTunnel === true || serverIsLocal(config) ? null : createServiceTunnels({
13680
15026
  listServices: () => fetchServices(config),