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

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 (95) hide show
  1. package/README.md +19 -0
  2. package/dist/{chunk-K5WNRH4V.js → chunk-DHM7VXTR.js} +119 -14
  3. package/dist/{dashboard-DQB5RNXJ.js → dashboard-OBFWHSPD.js} +8 -2
  4. package/dist/main.js +1620 -206
  5. package/man/mend-accounts.1 +1 -1
  6. package/man/mend-adopt.1 +1 -1
  7. package/man/mend-attach.1 +1 -1
  8. package/man/mend-codex.1 +1 -1
  9. package/man/mend-completions.1 +1 -1
  10. package/man/mend-connect-pi.1 +1 -1
  11. package/man/mend-connect.1 +1 -1
  12. package/man/mend-continue.1 +1 -1
  13. package/man/mend-doctor.1 +1 -1
  14. package/man/mend-dotfiles-repo.1 +1 -1
  15. package/man/mend-dotfiles-sync.1 +1 -1
  16. package/man/mend-dotfiles.1 +1 -1
  17. package/man/mend-env-cluster.1 +1 -1
  18. package/man/mend-env-load.1 +1 -1
  19. package/man/mend-env-show.1 +1 -1
  20. package/man/mend-folder-create.1 +1 -1
  21. package/man/mend-folder-list.1 +1 -1
  22. package/man/mend-folder-push.1 +1 -1
  23. package/man/mend-folder-rm.1 +1 -1
  24. package/man/mend-git-author.1 +1 -1
  25. package/man/mend-help.1 +1 -1
  26. package/man/mend-invite.1 +1 -1
  27. package/man/mend-keys-init.1 +1 -1
  28. package/man/mend-keys-mode.1 +1 -1
  29. package/man/mend-keys-share.1 +1 -1
  30. package/man/mend-keys-show.1 +1 -1
  31. package/man/mend-land.1 +1 -1
  32. package/man/mend-login.1 +1 -1
  33. package/man/mend-logout.1 +1 -1
  34. package/man/mend-logs.1 +13 -2
  35. package/man/mend-man.1 +1 -1
  36. package/man/mend-members.1 +1 -1
  37. package/man/mend-memory-import.1 +1 -1
  38. package/man/mend-memory-rm.1 +1 -1
  39. package/man/mend-memory-show.1 +1 -1
  40. package/man/mend-memory.1 +1 -1
  41. package/man/mend-models.1 +1 -1
  42. package/man/mend-operator-exposure.1 +1 -1
  43. package/man/mend-operator-gate.1 +1 -1
  44. package/man/mend-operator-grant-owner.1 +1 -1
  45. package/man/mend-operator-org-create.1 +1 -1
  46. package/man/mend-operator-org-invite-owner.1 +1 -1
  47. package/man/mend-operator-org-list.1 +1 -1
  48. package/man/mend-operator-org-rename.1 +1 -1
  49. package/man/mend-operator-reset-link.1 +1 -1
  50. package/man/mend-pair.1 +1 -1
  51. package/man/mend-projects.1 +1 -1
  52. package/man/mend-pull.1 +1 -1
  53. package/man/mend-refresh.1 +1 -1
  54. package/man/mend-rejoin.1 +1 -1
  55. package/man/mend-resume.1 +1 -1
  56. package/man/mend-run.1 +2 -2
  57. package/man/mend-secrets-add.1 +1 -1
  58. package/man/mend-secrets-rm.1 +1 -1
  59. package/man/mend-secrets.1 +1 -1
  60. package/man/mend-server-logs.1 +1 -1
  61. package/man/mend-server-restart.1 +1 -1
  62. package/man/mend-server-setup.1 +32 -2
  63. package/man/mend-server-start.1 +1 -1
  64. package/man/mend-server-status.1 +4 -2
  65. package/man/mend-server-stop.1 +2 -2
  66. package/man/mend-server-upgrade.1 +1 -1
  67. package/man/mend-server.1 +1 -1
  68. package/man/mend-service-add.1 +1 -1
  69. package/man/mend-service-connect.1 +1 -1
  70. package/man/mend-service-init.1 +1 -1
  71. package/man/mend-service-list.1 +10 -4
  72. package/man/mend-service-logs.1 +1 -1
  73. package/man/mend-service-restart.1 +1 -1
  74. package/man/mend-service-run.1 +13 -6
  75. package/man/mend-service-stop.1 +1 -1
  76. package/man/mend-session-share.1 +1 -1
  77. package/man/mend-sessions.1 +1 -1
  78. package/man/mend-shell.1 +1 -1
  79. package/man/mend-skills-push.1 +1 -1
  80. package/man/mend-skills.1 +1 -1
  81. package/man/mend-snake.1 +1 -1
  82. package/man/mend-ssh-keys-remove.1 +18 -0
  83. package/man/mend-ssh-keys.1 +15 -0
  84. package/man/mend-ssh-setup.1 +2 -2
  85. package/man/mend-ssh.1 +2 -2
  86. package/man/mend-stop.1 +1 -1
  87. package/man/mend-ui.1 +1 -1
  88. package/man/mend-uninstall.1 +2 -2
  89. package/man/mend-version.1 +1 -1
  90. package/man/mend-wait.1 +5 -5
  91. package/man/mend-workspace-replace.1 +1 -1
  92. package/man/mend-worktrees-rm.1 +1 -1
  93. package/man/mend-worktrees.1 +1 -1
  94. package/man/mend.1 +10 -4
  95. package/package.json +2 -2
package/dist/main.js CHANGED
@@ -36,10 +36,12 @@ import {
36
36
  hostUserNamespacesOf,
37
37
  isDetachChunk,
38
38
  isPasteChunk,
39
+ jsonWithoutCredentials,
39
40
  landingFactLine,
40
41
  landingFactsFromWire,
41
42
  launcherNameOf,
42
43
  matchProjectByCwd,
44
+ mayStillBeWorking,
43
45
  narrowCredential,
44
46
  noAnswerError,
45
47
  normalizeGitAuthor,
@@ -51,6 +53,7 @@ import {
51
53
  replaceConfirmLines,
52
54
  repositoryCloneUrlIssue,
53
55
  sameGrant,
56
+ serviceStartCorrelation,
54
57
  servicesHoldOf,
55
58
  sessionDisplayName,
56
59
  sessionWorkspaceLines,
@@ -68,11 +71,12 @@ import {
68
71
  workspaceRetirementLine,
69
72
  worktreeJoinLine,
70
73
  worktreeListsPeople
71
- } from "./chunk-K5WNRH4V.js";
74
+ } from "./chunk-DHM7VXTR.js";
72
75
 
73
76
  // src/main.ts
74
77
  import { spawn as spawn3, spawnSync as spawnSync6 } from "node:child_process";
75
- import * as fs20 from "node:fs";
78
+ import * as crypto from "node:crypto";
79
+ import * as fs21 from "node:fs";
76
80
  import { createRequire } from "node:module";
77
81
  import * as net5 from "node:net";
78
82
  import * as os13 from "node:os";
@@ -853,6 +857,421 @@ var EDGE_CADDYFILE = [
853
857
  "}"
854
858
  ].join("\n") + "\n";
855
859
 
860
+ // src/server-mirrors.ts
861
+ var NPM_MIRROR_IMAGE = "nginx:1.29-alpine";
862
+ var DOCKER_MIRROR_IMAGE = "registry:3.1";
863
+ var MIRRORS_COMPOSE_FILE = "compose.mirrors.yaml";
864
+ var NPM_MIRROR_CONF_NAME = "npm-mirror.conf";
865
+ var NPM_MIRROR_URL = "http://npm-mirror:4873/";
866
+ var DOCKER_MIRROR_URL = "http://docker-mirror:5000";
867
+ var DOCKER_MIRROR_CONTAINER = "mend-docker-mirror";
868
+ var DEFAULT_NPM_MIRROR_MAX_SIZE = "10g";
869
+ var DOCKER_MIRROR_TTL = "168h";
870
+ var DEFAULT_DOCKER_MIRROR_MAX_SIZE = "20g";
871
+ var MIRROR_MIN_FREE = "5g";
872
+ var DOCKER_MIRROR_GUARD_NAME = "docker-mirror-guard.sh";
873
+ var dockerMirrorLogin = (docker2) => docker2 !== null && "upstreamUser" in docker2 ? docker2.upstreamUser : void 0;
874
+ var DEFAULT_MIRRORS = {
875
+ npm: { maxSize: DEFAULT_NPM_MIRROR_MAX_SIZE },
876
+ docker: { maxSize: DEFAULT_DOCKER_MIRROR_MAX_SIZE }
877
+ };
878
+ var parseMirrorSize = (value) => {
879
+ const match = /^([1-9]\d{0,5})([mMgG])$/.exec(value.trim());
880
+ if (match === null) return null;
881
+ const [, amount = "", unit = ""] = match;
882
+ const size = `${amount}${unit.toLowerCase()}`;
883
+ return mirrorSizeBytes(size) >= 1024 ** 3 ? size : null;
884
+ };
885
+ var mirrorSizeBytes = (size) => {
886
+ const amount = Number(size.slice(0, -1));
887
+ return size.endsWith("g") ? amount * 1024 ** 3 : amount * 1024 ** 2;
888
+ };
889
+ var CREDENTIAL = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,254}$/;
890
+ var isDockerHubCredential = (value) => CREDENTIAL.test(value);
891
+ var runsMirrors = (mirrors) => mirrors !== void 0 && (mirrors.npm !== null || mirrors.docker !== null);
892
+ var mirrorsEnvLines = (mirrors, dockerHubToken) => {
893
+ const user = dockerMirrorLogin(mirrors?.docker ?? null);
894
+ return [
895
+ ...mirrors === void 0 || mirrors.npm === null ? [] : [`MEND_NPM_MIRROR_MAX_SIZE=${mirrors.npm.maxSize}`],
896
+ ...mirrors === void 0 || mirrors.docker === null ? [] : [`MEND_DOCKER_MIRROR_MAX_SIZE=${mirrors.docker.maxSize}`],
897
+ ...user === void 0 || dockerHubToken === void 0 ? [] : [`MEND_DOCKER_HUB_USERNAME=${user}`, `MEND_DOCKER_HUB_TOKEN=${dockerHubToken}`]
898
+ ];
899
+ };
900
+ var LOGGING = [
901
+ " logging:",
902
+ " driver: json-file",
903
+ " options:",
904
+ ' max-size: "20m"',
905
+ ' max-file: "5"'
906
+ ];
907
+ var HEALTH_TIMING = [
908
+ " interval: 10s",
909
+ " timeout: 5s",
910
+ " retries: 3",
911
+ " start_period: 10s"
912
+ ];
913
+ var npmMirrorService = () => [
914
+ " # Read-through cache of registry.npmjs.org (npm-mirror.conf). Size-capped, and it leaves",
915
+ ` # ${MIRROR_MIN_FREE} free on its disk; the least recently used tarballs go first. No credential sent.`,
916
+ " npm-mirror:",
917
+ ` image: ${NPM_MIRROR_IMAGE}`,
918
+ " hostname: npm-mirror",
919
+ " restart: unless-stopped",
920
+ " environment:",
921
+ ' NGINX_ENTRYPOINT_LOCAL_RESOLVERS: "1"',
922
+ ' NGINX_ENTRYPOINT_QUIET_LOGS: "1"',
923
+ " NPM_MIRROR_MAX_SIZE: ${MEND_NPM_MIRROR_MAX_SIZE:?set MEND_NPM_MIRROR_MAX_SIZE in server.env}",
924
+ ` NPM_MIRROR_MIN_FREE: ${MIRROR_MIN_FREE}`,
925
+ " volumes:",
926
+ ` - ./${NPM_MIRROR_CONF_NAME}:/etc/nginx/templates/default.conf.template:ro`,
927
+ " - mend-npm-mirror:/var/cache/npm-mirror",
928
+ " # Answered by nginx itself, so it says the mirror is up without asking the registry.",
929
+ " healthcheck:",
930
+ ' test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:4873/-/ping"]',
931
+ ...HEALTH_TIMING,
932
+ ...LOGGING
933
+ ];
934
+ var dockerMirrorService = (mirrors) => [
935
+ " # Pull-through cache of Docker Hub, run by its guard (docker-mirror-guard.sh): the cache stays",
936
+ ` # under its cap and leaves ${MIRROR_MIN_FREE} free on its disk. Layer data is deleted seven days after a`,
937
+ " # fetch. The metrics listener stays on the container's loopback.",
938
+ " docker-mirror:",
939
+ ` image: ${DOCKER_MIRROR_IMAGE}`,
940
+ ` container_name: ${DOCKER_MIRROR_CONTAINER}`,
941
+ " hostname: docker-mirror",
942
+ " restart: unless-stopped",
943
+ " init: true",
944
+ ` entrypoint: ["/bin/sh", "/mend/${DOCKER_MIRROR_GUARD_NAME}"]`,
945
+ " environment:",
946
+ " DOCKER_MIRROR_MAX_SIZE:",
947
+ " ${MEND_DOCKER_MIRROR_MAX_SIZE:?set MEND_DOCKER_MIRROR_MAX_SIZE in server.env}",
948
+ ` DOCKER_MIRROR_MIN_FREE: ${MIRROR_MIN_FREE}`,
949
+ " REGISTRY_PROXY_REMOTEURL: https://registry-1.docker.io",
950
+ ` REGISTRY_PROXY_TTL: ${DOCKER_MIRROR_TTL}`,
951
+ " REGISTRY_LOG_LEVEL: warn",
952
+ ' REGISTRY_LOG_ACCESSLOG_DISABLED: "true"',
953
+ " REGISTRY_HTTP_DEBUG_ADDR: 127.0.0.1:5001",
954
+ ' REGISTRY_HTTP_DEBUG_PROMETHEUS_ENABLED: "true"',
955
+ ...dockerMirrorLogin(mirrors.docker) === void 0 ? [] : [
956
+ " # A token the operator declared Public Repo Read-only: every session can pull what it reads.",
957
+ " REGISTRY_PROXY_USERNAME: ${MEND_DOCKER_HUB_USERNAME:?set MEND_DOCKER_HUB_USERNAME in server.env}",
958
+ " REGISTRY_PROXY_PASSWORD: ${MEND_DOCKER_HUB_TOKEN:?set MEND_DOCKER_HUB_TOKEN in server.env}"
959
+ ],
960
+ " volumes:",
961
+ ` - ./${DOCKER_MIRROR_GUARD_NAME}:/mend/${DOCKER_MIRROR_GUARD_NAME}:ro`,
962
+ " - mend-docker-mirror:/var/lib/registry",
963
+ " # Healthy while its guard keeps working, which it records every pass. The registry inside may",
964
+ " # be paused for want of disk, or not started because Docker Hub did not answer (it asks Hub as",
965
+ " # it starts, and the guard tries again every pass): setup does not wait for it.",
966
+ " healthcheck:",
967
+ ` test: ["CMD-SHELL", 'test -n "$$(find /tmp/mend-mirror-guard -mmin -2)"']`,
968
+ ...HEALTH_TIMING,
969
+ ...LOGGING
970
+ ];
971
+ var renderMirrorsOverlay = (mirrors) => {
972
+ if (!runsMirrors(mirrors)) return void 0;
973
+ return [
974
+ "# Written by mend server setup: the package and image mirrors this install runs, on the Compose",
975
+ "# network only (no host port). Values come from server.env. Sessions install npm packages and",
976
+ "# pull Docker Hub images through them; a mirror that is down sends them to the registry itself.",
977
+ "services:",
978
+ " mend:",
979
+ " environment:",
980
+ ...mirrors.npm === null ? [] : [` MEND_NPM_MIRROR_URL: ${NPM_MIRROR_URL}`],
981
+ ...mirrors.docker === null ? [] : [
982
+ ` SEALANT_DOCKER_REGISTRY_MIRRORS: ${DOCKER_MIRROR_URL}`,
983
+ ` SEALANT_DOCKER_REGISTRY_MIRROR_CONTAINER: ${DOCKER_MIRROR_CONTAINER}`
984
+ ],
985
+ "",
986
+ ...mirrors.npm === null ? [] : [...npmMirrorService(), ""],
987
+ ...mirrors.docker === null ? [] : [...dockerMirrorService(mirrors), ""],
988
+ "volumes:",
989
+ ...mirrors.npm === null ? [] : [" mend-npm-mirror:"],
990
+ ...mirrors.docker === null ? [] : [" mend-docker-mirror:"],
991
+ ""
992
+ ].join("\n");
993
+ };
994
+ var mirrorServices = (mirrors) => [
995
+ ...mirrors === void 0 || mirrors.npm === null ? [] : ["npm-mirror"],
996
+ ...mirrors === void 0 || mirrors.docker === null ? [] : ["docker-mirror"]
997
+ ];
998
+ var mirrorImagesOf = (overlay) => [...overlay.matchAll(/^ {4}image:[ \t]*((?:nginx|registry):[^\s#]+)[ \t]*$/gm)].map((match) => match[1]).filter((image) => image !== void 0);
999
+ var NPM_MIRROR_CONF = `# Mend's npm mirror: a read-through cache of the public npm registry (registry.npmjs.org) for the
1000
+ # workspaces on this host. nginx keeps what it fetched and serves it again. The package manager
1001
+ # checks every tarball against its lockfile's integrity hash, so the cache never decides what is
1002
+ # installed. Nothing is published through it, and no credential is sent upstream. When the
1003
+ # registry fails, a copy already held is served instead.
1004
+ proxy_cache_path /var/cache/npm-mirror levels=2:2 keys_zone=npm:64m
1005
+ max_size=\${NPM_MIRROR_MAX_SIZE} min_free=\${NPM_MIRROR_MIN_FREE}
1006
+ inactive=90d use_temp_path=off;
1007
+
1008
+ # Abbreviated metadata (what installs ask for) and full metadata are different documents.
1009
+ map $http_accept $npm_document {
1010
+ "~application/vnd\\.npm\\.install-v1\\+json" abbreviated;
1011
+ default full;
1012
+ }
1013
+
1014
+ # One JSON line per request: \`mend server status\` reads the cache field of the tarball lines.
1015
+ log_format npm_mirror escape=json
1016
+ '{"time":"$time_iso8601","method":"$request_method","uri":"$uri","status":$status,'
1017
+ '"cache":"$upstream_cache_status","bytes":$body_bytes_sent}';
1018
+
1019
+ server {
1020
+ listen 4873;
1021
+ access_log /dev/stdout npm_mirror;
1022
+ error_log /dev/stderr warn;
1023
+ client_max_body_size 8m;
1024
+
1025
+ resolver \${NGINX_LOCAL_RESOLVERS} valid=60s ipv6=off;
1026
+ resolver_timeout 5s;
1027
+ set $npm_registry registry.npmjs.org;
1028
+
1029
+ proxy_http_version 1.1;
1030
+ proxy_set_header Host registry.npmjs.org;
1031
+ proxy_set_header Connection "";
1032
+ # Never forward a credential: the mirror serves the public registry, the same to everyone.
1033
+ proxy_set_header Authorization "";
1034
+ proxy_set_header Cookie "";
1035
+ # Uncompressed from upstream, so one cached copy serves every client; gzip below compresses it.
1036
+ proxy_set_header Accept-Encoding "";
1037
+ proxy_ssl_server_name on;
1038
+ proxy_ssl_name registry.npmjs.org;
1039
+ proxy_ssl_verify on;
1040
+ proxy_ssl_verify_depth 4;
1041
+ proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
1042
+ proxy_connect_timeout 10s;
1043
+ proxy_read_timeout 60s;
1044
+ proxy_ignore_headers Cache-Control Expires Set-Cookie Vary;
1045
+ proxy_hide_header Set-Cookie;
1046
+ proxy_cache_lock on;
1047
+ proxy_cache_lock_timeout 120s;
1048
+ proxy_cache_use_stale error timeout invalid_header updating http_500 http_502 http_503 http_504 http_429;
1049
+ proxy_cache_background_update on;
1050
+ add_header X-Mend-Mirror $upstream_cache_status always;
1051
+
1052
+ gzip on;
1053
+ gzip_proxied any;
1054
+ gzip_types application/json application/vnd.npm.install-v1+json;
1055
+
1056
+ # Answered here, so a client can tell the mirror is up without asking the registry.
1057
+ location = /-/ping {
1058
+ default_type application/json;
1059
+ return 200 '{}';
1060
+ }
1061
+
1062
+ # Tarballs never change once published: kept until the cache needs the room.
1063
+ location ~ ^/(?:@[^/]+/)?[^/]+/-/[^/]+\\.tgz$ {
1064
+ limit_except GET HEAD { deny all; }
1065
+ proxy_cache npm;
1066
+ proxy_cache_key $uri;
1067
+ proxy_cache_valid 200 365d;
1068
+ proxy_pass https://$npm_registry;
1069
+ }
1070
+
1071
+ # Audit reports go to the registry as they are, never cached.
1072
+ location /-/npm/v1/security/ {
1073
+ limit_except POST { deny all; }
1074
+ proxy_pass https://$npm_registry;
1075
+ }
1076
+
1077
+ # Metadata: fresh for five minutes, then revalidated; served stale while the registry fails.
1078
+ # The key is the whole request, query included: a search for one package never answers another.
1079
+ location / {
1080
+ limit_except GET HEAD { deny all; }
1081
+ proxy_cache npm;
1082
+ proxy_cache_key $request_uri|$npm_document;
1083
+ proxy_cache_valid 200 5m;
1084
+ proxy_cache_valid 404 1m;
1085
+ proxy_cache_revalidate on;
1086
+ proxy_pass https://$npm_registry;
1087
+ }
1088
+ }
1089
+ `;
1090
+ var DOCKER_MIRROR_GUARD = `#!/bin/sh
1091
+ # Mend's Docker mirror guard (written by mend server setup): runs the registry, and keeps its cache
1092
+ # under a byte cap and the disk it lives on above a floor. Over the cap, the cache is cleared and
1093
+ # fills again from Docker Hub. Below the floor, the cache is cleared and the registry stays stopped
1094
+ # until there is room again; meanwhile session Docker daemons pull from Docker Hub directly. The
1095
+ # registry has no size cap of its own, and its seven-day expiry deletes layer data only.
1096
+ set -u
1097
+ root=/var/lib/registry
1098
+ state=/tmp/mend-mirror-guard
1099
+ size_mb() {
1100
+ case "$1" in
1101
+ *g) echo $((\${1%g} * 1024)) ;;
1102
+ *m) echo "\${1%m}" ;;
1103
+ *) echo "mend docker mirror guard: cannot read size $1" >&2 && exit 64 ;;
1104
+ esac
1105
+ }
1106
+ cap=$(size_mb "\${DOCKER_MIRROR_MAX_SIZE:?}") || exit 64
1107
+ floor=$(size_mb "\${DOCKER_MIRROR_MIN_FREE:?}") || exit 64
1108
+ interval=\${DOCKER_MIRROR_GUARD_INTERVAL:-30}
1109
+ pid=
1110
+ stop_registry() {
1111
+ if [ -n "$pid" ]; then
1112
+ kill "$pid" 2>/dev/null
1113
+ wait "$pid" 2>/dev/null
1114
+ fi
1115
+ pid=
1116
+ }
1117
+ clear_cache() { rm -rf "$root/docker" "$root/scheduler-state.json"; }
1118
+ cache_held() { [ -e "$root/docker" ] || [ -e "$root/scheduler-state.json" ]; }
1119
+ trap 'stop_registry; exit 0' TERM INT
1120
+ while :; do
1121
+ free=$(df -Pm "$root" | awk 'NR == 2 { print $4 }')
1122
+ if [ "$free" -lt "$floor" ]; then
1123
+ # Below the floor: no registry, and no cache, whether the registry was running or this is a
1124
+ # start with a cache left from before. Once the space is back, the next pass starts it again.
1125
+ stop_registry
1126
+ # What is left, looked at again after clearing: the log and the status say what this finds,
1127
+ # not what was attempted.
1128
+ if cache_held; then
1129
+ clear_cache
1130
+ if cache_held; then outcome="cache could not be cleared"; else outcome="cache cleared"; fi
1131
+ echo "mend docker mirror guard: \${free} MiB free on its disk, below \${floor} MiB: \${outcome}, registry paused" >&2
1132
+ fi
1133
+ if cache_held; then held=kept; else held=none; fi
1134
+ echo "paused \${free} \${floor} \${held}" >"$state"
1135
+ else
1136
+ used=$(du -sm "$root" | cut -f1)
1137
+ if [ "$used" -gt "$cap" ]; then
1138
+ stop_registry
1139
+ clear_cache
1140
+ if cache_held; then outcome="could not be cleared"; else outcome="cleared"; fi
1141
+ echo "mend docker mirror guard: cache \${used} MiB, over its \${cap} MiB cap: \${outcome}" >&2
1142
+ fi
1143
+ if [ -z "$pid" ] || ! kill -0 "$pid" 2>/dev/null; then
1144
+ registry serve /etc/distribution/config.yml &
1145
+ pid=$!
1146
+ fi
1147
+ echo "running" >"$state"
1148
+ fi
1149
+ sleep "$interval" &
1150
+ wait $!
1151
+ done
1152
+ `;
1153
+ var formatBytes = (bytes) => {
1154
+ if (bytes < 1024) return `${bytes} B`;
1155
+ const units = ["KiB", "MiB", "GiB", "TiB"];
1156
+ let value = bytes / 1024;
1157
+ let unit = 0;
1158
+ while (value >= 1024 && unit < units.length - 1) {
1159
+ value /= 1024;
1160
+ unit += 1;
1161
+ }
1162
+ return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`;
1163
+ };
1164
+ var percent = (part, whole) => whole === 0 ? "" : ` (${Math.round(part / whole * 100)}%)`;
1165
+ 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`;
1166
+ var mirrorDiskOf = (stdout) => {
1167
+ const [used, free, guard = ""] = stdout.trim().split("\n").map((line) => line.trim());
1168
+ const usedKiB = Number(used);
1169
+ const freeKiB = Number(free);
1170
+ if (used === void 0 || free === void 0 || used === "" || free === "") return null;
1171
+ if (!Number.isFinite(usedKiB) || !Number.isFinite(freeKiB)) return null;
1172
+ const paused = /^paused (\d+) (\d+)(?: (none|kept))?$/.exec(guard);
1173
+ return {
1174
+ used: usedKiB * 1024,
1175
+ free: freeKiB * 1024,
1176
+ guard: paused?.[1] !== void 0 && paused[2] !== void 0 ? {
1177
+ state: "paused",
1178
+ freeMiB: Number(paused[1]),
1179
+ floorMiB: Number(paused[2]),
1180
+ cache: paused[3] === "none" || paused[3] === "kept" ? paused[3] : null
1181
+ } : guard === "running" ? { state: "running" } : null
1182
+ };
1183
+ };
1184
+ var FROM_CACHE = /* @__PURE__ */ new Set(["HIT", "STALE", "UPDATING", "REVALIDATED"]);
1185
+ var FROM_UPSTREAM = /* @__PURE__ */ new Set(["MISS", "EXPIRED", "BYPASS"]);
1186
+ var npmMirrorTraffic = (log) => {
1187
+ let tarballs = 0;
1188
+ let fromCache = 0;
1189
+ let fromRegistry = 0;
1190
+ for (const line of log.split("\n")) {
1191
+ const start = line.indexOf("{");
1192
+ if (start < 0) continue;
1193
+ let fields;
1194
+ try {
1195
+ fields = JSON.parse(line.slice(start));
1196
+ } catch {
1197
+ continue;
1198
+ }
1199
+ if (typeof fields !== "object" || fields === null) continue;
1200
+ const entry = new Map(Object.entries(fields));
1201
+ const uri = entry.get("uri");
1202
+ const cache = entry.get("cache");
1203
+ if (typeof uri !== "string" || !uri.endsWith(".tgz") || typeof cache !== "string") continue;
1204
+ tarballs += 1;
1205
+ if (FROM_CACHE.has(cache)) fromCache += 1;
1206
+ else if (FROM_UPSTREAM.has(cache)) fromRegistry += 1;
1207
+ }
1208
+ return { tarballs, fromCache, fromRegistry };
1209
+ };
1210
+ var dockerMirrorTraffic = (metrics) => {
1211
+ const counter = (name, type2) => {
1212
+ const match = new RegExp(`^registry_proxy_${name}_total\\{type="${type2}"\\} (\\d+)`, "m").exec(
1213
+ metrics
1214
+ );
1215
+ return match?.[1] === void 0 ? null : Number(match[1]);
1216
+ };
1217
+ const values = [
1218
+ counter("hits", "blob"),
1219
+ counter("misses", "blob"),
1220
+ counter("hits", "manifest"),
1221
+ counter("misses", "manifest")
1222
+ ];
1223
+ const [blobHits, blobMisses, manifestHits, manifestMisses] = values;
1224
+ if (blobHits === void 0 || blobHits === null || blobMisses === void 0 || blobMisses === null || manifestHits === void 0 || manifestHits === null || manifestMisses === void 0 || manifestMisses === null) {
1225
+ return null;
1226
+ }
1227
+ return {
1228
+ blobs: { hits: blobHits, misses: blobMisses },
1229
+ manifests: { hits: manifestHits, misses: manifestMisses }
1230
+ };
1231
+ };
1232
+ 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`;
1233
+ var observedNpmMirrorLine = (mirrors, observed) => {
1234
+ if (mirrors.npm === null)
1235
+ return "npm mirror \xB7 off on this install \xB7 mend server setup --npm-mirror turns it on";
1236
+ if (!observed.running)
1237
+ return "npm mirror \xB7 container not running \xB7 sessions install from registry.npmjs.org directly";
1238
+ const { traffic } = observed;
1239
+ 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`;
1240
+ return [
1241
+ "npm mirror \xB7 running",
1242
+ diskWords(observed.disk, mirrors.npm.maxSize),
1243
+ seen,
1244
+ "observed"
1245
+ ].join(" \xB7 ");
1246
+ };
1247
+ var observedDockerMirrorLine = (mirrors, observed) => {
1248
+ if (mirrors.docker === null)
1249
+ return "docker mirror \xB7 off on this install \xB7 mend server setup --docker-mirror turns it on";
1250
+ if (!observed.running)
1251
+ return "docker mirror \xB7 container not running \xB7 session Docker daemons pull from Docker Hub directly";
1252
+ const { disk, traffic } = observed;
1253
+ if (typeof disk !== "string" && disk.guard?.state === "paused")
1254
+ return [
1255
+ "docker mirror \xB7 paused by its disk guard",
1256
+ `${formatBytes(disk.guard.freeMiB * 1024 ** 2)} free on its disk, below ${formatBytes(disk.guard.floorMiB * 1024 ** 2)}`,
1257
+ 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",
1258
+ "session Docker daemons pull from Docker Hub directly until there is room",
1259
+ "observed"
1260
+ ].join(" \xB7 ");
1261
+ const since = observed.startedAt === null ? "since it started" : `since ${observed.startedAt}`;
1262
+ 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`;
1263
+ const user = dockerMirrorLogin(mirrors.docker);
1264
+ 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`;
1265
+ return [
1266
+ "docker mirror \xB7 running",
1267
+ diskWords(disk, mirrors.docker.maxSize),
1268
+ "layers evicted 7 days after each fetch",
1269
+ seen,
1270
+ login2,
1271
+ "observed"
1272
+ ].join(" \xB7 ");
1273
+ };
1274
+
856
1275
  // src/server-edge.ts
857
1276
  var EXPOSURES = ["loopback", "private", "public"];
858
1277
  var TENANCIES = ["single", "multi"];
@@ -909,7 +1328,8 @@ var renderPostureOverlay = (posture) => {
909
1328
  };
910
1329
  var composeOverlays = (posture) => [
911
1330
  ...posture.edgeHost === void 0 ? [] : [EDGE_COMPOSE_FILE],
912
- ...renderPostureOverlay(posture) === void 0 ? [] : [POSTURE_COMPOSE_FILE]
1331
+ ...renderPostureOverlay(posture) === void 0 ? [] : [POSTURE_COMPOSE_FILE],
1332
+ ...runsMirrors(posture.mirrors) ? [MIRRORS_COMPOSE_FILE] : []
913
1333
  ];
914
1334
  var record = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? new Map(Object.entries(value)) : null;
915
1335
  var healthPosture = (body) => {
@@ -1980,7 +2400,7 @@ var COMMANDS = [
1980
2400
  "The same worktree, record, and review as mend codex, with a command of your own in place of a harness. Everything after -- is the command.",
1981
2401
  "The command's output is printed as the record has it, and mend run exits with the command's exit code. It runs in a terminal, so stdout and stderr arrive together, on stdout. What Mend says itself goes to stderr, so out=$(mend run -- git log -1) holds the command's output and nothing else. Ctrl+C stops watching and puts the terminal back; the command keeps running, and mend logs and mend wait pick it up again. A signal exits 128 + its number: 130 for SIGINT (Ctrl+C), 129 for SIGHUP, 143 for SIGTERM. A second signal exits at once, and an exit waits at most 5 seconds for a reader that takes nothing, then says the output may be incomplete.",
1982
2402
  "Output this terminal could not be given in full (a read the server refused, a reader that went away) fails mend run with exit 1 even when the command succeeded, and the command's own code is said on stderr: a script never takes cut output for the whole of it.",
1983
- "The platform takes at most 64 words, none empty and none starting or ending with whitespace (a script that starts with a newline, for example). Such a command is refused before anything is created; trim the word and run it again.",
2403
+ "The platform takes at most 64 words and 1 MiB, at most 131,071 bytes a word, a program with no leading or trailing whitespace, and no NUL byte. An argument may be empty, start with a newline or span lines. A command it would refuse is refused before anything is created.",
1984
2404
  "With --detach, mend run returns once the command runs. With --json, stdout carries one JSON object in place of the output: the session id, the process id, the worktree, the branch, and the process's status and exit code as last observed, which is how it ended without --detach (and with it, when the command ended first).",
1985
2405
  "Workspaces set PAGER=cat, so git log and friends print instead of waiting for a pager the image does not have. A project variable of the same name, or the pager in your own git config, wins."
1986
2406
  ],
@@ -2005,31 +2425,39 @@ var COMMANDS = [
2005
2425
  name: "logs",
2006
2426
  section: "sessions",
2007
2427
  summary: "print a session's recorded terminal output",
2008
- synopsis: ["[session] [--follow] [--from <sequence>] [--process <id>]"],
2428
+ synopsis: [
2429
+ "[session] [--follow] [--from <sequence>] [--process <id>]",
2430
+ "[session] --service <name-or-id> [--follow] [--from <sequence>]"
2431
+ ],
2009
2432
  description: [
2010
2433
  "Prints what the session's command (or agent) wrote to its terminal, as the record holds it, on stdout. Settled sessions count: the record outlives the process and the workspace. --follow keeps printing until the process ends. The record is printed as it is, nothing taken out: anyone who can read the project can read it, so what a command printed, a password included, reaches them too. A signal stops it and puts the terminal back, exiting 128 + its number: 130 for SIGINT (Ctrl+C), 129 for SIGHUP, 143 for SIGTERM.",
2011
- "<session> is the session id, a prefix of it, or the worktree's name. With none, the one live session is taken. --process reads another process of the session, a shell or a Service attempt, by a prefix of its id."
2434
+ "<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.",
2435
+ "--service reads a Service's current attempt, by the Service's id, its name or a prefix of its id, the ids mend service list prints. A full id names its Service before any name does. A name two Services carry is refused, with both ids listed; name one by its id, or name the session. A Service with no attempt (an adopted port, which Mend runs no process for) has nothing recorded, and mend logs says so and exits 1."
2012
2436
  ],
2013
2437
  options: [
2014
2438
  { flag: "--follow, -f", text: "keep printing until the process ends" },
2015
2439
  { flag: "--from <sequence>", text: "start at a record sequence. Default: 0" },
2016
- { flag: "--process <id>", text: "another process of the session, by a prefix of its id" }
2440
+ { flag: "--process <id>", text: "another process of the session, by a prefix of its id" },
2441
+ { flag: "--service <name-or-id>", text: "a Service's current attempt" }
2442
+ ],
2443
+ examples: [
2444
+ { command: "mend logs 3f2a --follow", text: "" },
2445
+ { command: "mend logs --service web --follow", text: "the Service web's output, live" }
2017
2446
  ],
2018
- examples: [{ command: "mend logs 3f2a --follow", text: "" }],
2019
2447
  see: ["run", "wait", "service logs"]
2020
2448
  },
2021
2449
  {
2022
2450
  name: "wait",
2023
2451
  section: "sessions",
2024
2452
  summary: "wait for a session's command to end",
2025
- synopsis: ["[session] [--timeout <seconds>] [--process <id>] [--json]"],
2453
+ synopsis: ["[session] [--timeout <duration>] [--process <id>] [--json]"],
2026
2454
  description: [
2027
2455
  "Returns once the session's command (or agent) has ended, with its exit code: the code the platform reported, or 1 when it reported none. A command that already ended answers at once. While a launch or a resume is starting, the previous process's end does not count. --process waits for one process by its id, the processId mend run --json prints.",
2028
- "When --timeout passes first, mend wait exits 124, as timeout(1) does, and the command keeps running. Stopped by a signal, it exits 128 + its number (130 for Ctrl+C), and the command keeps running. The timeout covers everything: finding the session, every read and every retry. --json then prints the last state read, and nothing more is asked of the server.",
2456
+ "A session that is still starting (its workspace building, its image pulling) is waited through: only its end counts. When --timeout passes first, mend wait exits 124, as timeout(1) does, and the command keeps running. A duration is seconds (90, 90s or .5), minutes (5m) or hours (1h); with none, mend wait waits as long as the command runs. Stopped by a signal, it exits 128 + its number (130 for Ctrl+C), and the command keeps running. The timeout covers everything: finding the session, every read and every retry. --json then prints the last state read, and nothing more is asked of the server.",
2029
2457
  "<session> is the session id, a prefix of it, or the worktree's name. With none, the one live session is taken."
2030
2458
  ],
2031
2459
  options: [
2032
- { flag: "--timeout <seconds>", text: "give up after this long; exit 124" },
2460
+ { flag: "--timeout <duration>", text: "give up after this long (90, 90s, 5m, 1h); exit 124" },
2033
2461
  {
2034
2462
  flag: "--process <id>",
2035
2463
  text: "wait for this process of the session, by a prefix of its id"
@@ -2230,18 +2658,22 @@ var COMMANDS = [
2230
2658
  section: "services",
2231
2659
  summary: "start and supervise a server in the session's workspace",
2232
2660
  synopsis: [
2233
- "[session] --port <port> [--name <n>] [--udp] [--http|--https] [--wait] [--no-connect] -- <command...>",
2234
- "[session] <name> [--wait] [--no-connect]"
2661
+ "[session] --port <port> [--name <n>] [--udp] [--http|--https] [--wait [--timeout <duration>]] [--no-connect] -- <command...>",
2662
+ "[session] <name> [--wait [--timeout <duration>]] [--no-connect]"
2235
2663
  ],
2236
2664
  description: [
2237
2665
  "With --, the command after it is started in the workspace and supervised: its output is recorded, and mend service restart re-runs it. Without --, the name is a Service declared in the worktree's mend.toml. mend service <name> is the shorthand for that.",
2238
- "Mend waits up to a minute for the port to answer before it returns. --wait makes the exit status say how that ended: 0 once the port answered, 1 when it did not, and 124 when the server gave no answer within 90 seconds. The Service keeps running in every case. A waited start returns and opens no tunnel; mend service connect reaches the port. UDP has no probe, and a recipe that declares only a port is adopted with one probe, so --wait takes neither.",
2666
+ "Mend waits up to a minute for the port to answer before it returns. --wait keeps waiting while the Service is still starting (its process runs and its port has not answered yet: building, installing, booting), however long that takes, up to --timeout (default 10 minutes). Mend probes a started Service's port every 20 seconds, so the wait ends at the first probe that answers. The wait judges the one process this start began, by an id the start sends and the server stamps on it, whatever another client starts, restarts or stops meanwhile. The exit status says how it ended: 0 the port answered for that process; 1 Mend refused the start or could not be asked; 2 the process ended before its port answered (the line says its status and exit code; a process its workspace took reads exited, no exit code reported); 3 the server no longer has the session, its workspace gone with it; 124 the Service was still starting when the timeout passed, and it keeps starting. A server older than this CLI stamps no id: a start whose answer an edge cut is then not followed (1), and a command that exits inside the minute reads as the refusal it answers with (1). A waited start returns and opens no tunnel; mend service connect reaches the port. UDP has no probe, and a recipe that declares only a port is adopted with one probe, so --wait takes neither.",
2239
2667
  "The port is tunnelled to this machine's loopback as soon as it listens, unless --no-connect."
2240
2668
  ],
2241
2669
  options: [
2242
2670
  {
2243
2671
  flag: "--wait",
2244
- text: "exit 1 when the port did not answer, 124 with no answer in 90 s; no tunnel"
2672
+ text: "return once the port answers: 0; process ended 2, session gone 3; no tunnel"
2673
+ },
2674
+ {
2675
+ flag: "--timeout <duration>",
2676
+ text: "bound --wait (90, 90s, 5m, 1h); still starting then exits 124. Default: 10m"
2245
2677
  },
2246
2678
  { flag: "--port <port>", text: "the port the command listens on inside the workspace" },
2247
2679
  { flag: "--name <n>", text: "the service's name. Default: the command" },
@@ -2251,9 +2683,13 @@ var COMMANDS = [
2251
2683
  ],
2252
2684
  examples: [
2253
2685
  { command: "mend service run --port 3000 -- pnpm dev", text: "" },
2254
- { command: "mend service web", text: "the Service named web in mend.toml" }
2686
+ { command: "mend service web", text: "the Service named web in mend.toml" },
2687
+ {
2688
+ command: "mend service run web --wait --timeout 15m",
2689
+ text: "returns once web answers; 124 if it is still starting after 15 minutes"
2690
+ }
2255
2691
  ],
2256
- see: ["service init", "service connect", "service logs"]
2692
+ see: ["service init", "service connect", "service logs", "logs"]
2257
2693
  },
2258
2694
  {
2259
2695
  name: "service add",
@@ -2286,9 +2722,13 @@ var COMMANDS = [
2286
2722
  name: "service list",
2287
2723
  section: "services",
2288
2724
  summary: "every live service and its observed state",
2289
- synopsis: [],
2290
- description: ["Name, session, port, whether it listens, and where it is reachable from here."],
2291
- see: ["service run", "service connect"]
2725
+ synopsis: ["[--json]"],
2726
+ description: [
2727
+ "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.",
2728
+ "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)."
2729
+ ],
2730
+ options: [{ flag: "--json", text: "print the Services as JSON" }],
2731
+ see: ["service run", "service connect", "logs"]
2292
2732
  },
2293
2733
  {
2294
2734
  name: "service logs",
@@ -2807,13 +3247,14 @@ var COMMANDS = [
2807
3247
  section: "this machine",
2808
3248
  summary: "install or repair the local Mend server",
2809
3249
  synopsis: [
2810
- "[--context <name>] [--version <version|latest>] [--bind <ip>] [--ssh-bind <ip>] [--url <origin>] [--origin <origin>...] [--port <n>] [--ssh-port <n>] [--edge <host> | --no-edge] [--exposure <loopback|private|public>] [--tenancy <single|multi>] [--declare <item>...] [--docker-socket <path>] [--assets-dir <dir>] [--offline]"
3250
+ "[--context <name>] [--version <version|latest>] [--bind <ip>] [--ssh-bind <ip>] [--url <origin>] [--origin <origin>...] [--port <n>] [--ssh-port <n>] [--edge <host> | --no-edge] [--exposure <loopback|private|public>] [--tenancy <single|multi>] [--declare <item>...] [--npm-mirror | --no-npm-mirror] [--npm-mirror-max-size <size>] [--docker-mirror | --no-docker-mirror] [--docker-mirror-max-size <size>] [--docker-hub-username <name> --docker-hub-token-stdin --docker-hub-public-only | --no-docker-hub-login] [--docker-socket <path>] [--assets-dir <dir>] [--offline]"
2811
3251
  ],
2812
3252
  description: [
2813
3253
  "Checks a local Unix-socket Docker context and the Compose plugin, downloads the compose and Postgres initialization assets for one Mend release, preserves existing data and secrets, and starts the server. Re-running repairs the same pinned version. A changed --version is refused; use mend server upgrade. Updating this CLI never updates an existing server pin.",
2814
3254
  "The default listens only on localhost at http://localhost:3105. Non-local access requires both --bind and --url. Every extra browser origin must be named with --origin; setup never guesses from the request Host header or network interfaces.",
2815
3255
  "--edge <host> runs a TLS edge in front of Mend: Caddy on ports 80 and 443 of every interface, which obtains and renews a certificate for the host and proxies to Mend's web tier. Mend's own port stays on loopback and the browser origin is https://<host>. The edge's compose overlay and Caddyfile are written into the generation beside compose.yaml, so start, restart and upgrade run them every time. --no-edge takes it away again, and the edge's container with it. A fresh install cannot start with the edge: until the first account exists, registration is open to whoever reaches the origin first, so set up on localhost, create the account, then add the edge.",
2816
3256
  "--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.",
3257
+ "Two mirrors run beside Mend unless turned off: an npm mirror (nginx caching npmjs.org, capped at 10g by default, least recently used out) and a Docker mirror (a pull-through cache of Docker Hub, capped at 20g by default: over the cap its cache is cleared). Both leave 5g free on their disk: below that, nginx evicts and the Docker mirror pauses, and sessions pull from Docker Hub directly. Neither publishes a host port. Sessions install npm packages and pull Docker Hub images through them; a project's own npm settings win, and a mirror that is down sends sessions upstream instead. Both are kept across reruns and upgrades, and an upgrade adds them to an install from before them. The Docker mirror pulls anonymously unless given a Docker Hub login: --docker-hub-username with the access token piped on standard input, kept in server.env only. The mirror has no login of its own, so every session that reaches it can pull whatever that token can read: setup takes a login only with --docker-hub-public-only, your statement that the token's access permission is Public Repo Read-only. Mend cannot check a token's scope.",
2817
3258
  "Docker Desktop on Linux and macOS, and OrbStack on macOS, expose client-side proxy sockets. Containers use the daemon-side /var/run/docker.sock. --docker-socket overrides detection and is retained on reruns.",
2818
3259
  "Setup holds an exclusive process lock through startup and health checks. A busy lock reports its owner and manual recovery steps. Never remove a live lock. Private configuration uses immutable generations and an atomic active pointer; failed attempts retain credentials and never delete Docker volumes."
2819
3260
  ],
@@ -2858,6 +3299,35 @@ var COMMANDS = [
2858
3299
  flag: "--declare <item>",
2859
3300
  text: "state a gate item you verified from outside: core-private, edge-tls or workspace-ssh; repeat for more, none clears. Kept across reruns. public with --ssh-bind beyond loopback needs workspace-ssh"
2860
3301
  },
3302
+ {
3303
+ flag: "--npm-mirror, --no-npm-mirror",
3304
+ text: "run or stop the npm mirror; kept across reruns and upgrades. Default: on"
3305
+ },
3306
+ {
3307
+ flag: "--npm-mirror-max-size <size>",
3308
+ text: "the npm mirror's disk cap, such as 20g or 1536m, at least 1g. Default: 10g"
3309
+ },
3310
+ {
3311
+ flag: "--docker-mirror, --no-docker-mirror",
3312
+ text: "run or stop the Docker Hub mirror; kept across reruns and upgrades. Default: on"
3313
+ },
3314
+ {
3315
+ flag: "--docker-mirror-max-size <size>",
3316
+ text: "the Docker mirror's cap, such as 40g; over it the cache is cleared and fills again. Default: 20g"
3317
+ },
3318
+ {
3319
+ flag: "--docker-hub-username <name>",
3320
+ text: "the Docker mirror pulls as this Docker Hub account; needs --docker-hub-token-stdin"
3321
+ },
3322
+ {
3323
+ flag: "--docker-hub-token-stdin",
3324
+ text: "read that account's access token from standard input; kept in server.env, never in argv or a session"
3325
+ },
3326
+ {
3327
+ flag: "--docker-hub-public-only",
3328
+ text: "required with a login: you state the token is scoped Public Repo Read-only, since every session can pull what it can read"
3329
+ },
3330
+ { flag: "--no-docker-hub-login", text: "the Docker mirror pulls anonymously again" },
2861
3331
  {
2862
3332
  flag: "--assets-dir <dir>",
2863
3333
  text: "copy compose.v2.yaml and postgres-init.sh from a release directory; fresh setup requires --version"
@@ -2889,6 +3359,10 @@ var COMMANDS = [
2889
3359
  {
2890
3360
  command: "mend server setup --exposure public --tenancy multi",
2891
3361
  text: "on an install with an edge and a first account: declare public, many organizations"
3362
+ },
3363
+ {
3364
+ command: 'printf %s "$DOCKER_HUB_TOKEN" | mend server setup --docker-hub-username mendbot --docker-hub-token-stdin --docker-hub-public-only',
3365
+ text: "the Docker mirror pulls from Docker Hub as mendbot, with a Public Repo Read-only token"
2892
3366
  }
2893
3367
  ],
2894
3368
  see: ["server", "server status", "login", "doctor", "operator exposure"]
@@ -2896,10 +3370,11 @@ var COMMANDS = [
2896
3370
  {
2897
3371
  name: "server status",
2898
3372
  section: "this machine",
2899
- summary: "show the pin, generation, containers, edge and posture",
3373
+ summary: "show the pin, generation, containers, edge, mirrors and posture",
2900
3374
  synopsis: [""],
2901
3375
  description: [
2902
3376
  "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.",
3377
+ "Each mirror the install runs follows, as observed: whether its container runs, what its cache holds, and its traffic. For the npm mirror, the tarball requests in its log for the last 24 hours and how many the cache served; for the Docker mirror, layer and manifest requests since it started and how many the cache served.",
2903
3378
  "Then the posture, declared beside observed. Declared is what this install's configuration says: the edge host, MEND_EXPOSURE and MEND_TENANCY, a default named as one. Observed is what was seen: whether the edge's container runs and whether Caddy's data holds a certificate for the host, and what the running server reports in its health, the exposure it runs with and how many public exposure gate items are open, the tenancy and which multi mode gate items are open. When this machine is signed in to the install as the operator, every item of both gates follows with its detail, as mend operator gate and mend operator exposure print them. None of it is a verdict: the report says what was declared and what was observed."
2904
3379
  ],
2905
3380
  see: ["server logs", "server start", "operator gate", "operator exposure"]
@@ -2918,7 +3393,7 @@ var COMMANDS = [
2918
3393
  {
2919
3394
  name: "server stop",
2920
3395
  section: "this machine",
2921
- summary: "stop Mend, Postgres, Garage and the edge without deleting data",
3396
+ summary: "stop the server's containers without deleting data",
2922
3397
  synopsis: [""],
2923
3398
  description: [
2924
3399
  "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."
@@ -2998,7 +3473,7 @@ var COMMANDS = [
2998
3473
  summary: "workspace SSH status: gateway, registered keys, ssh config",
2999
3474
  synopsis: ["[status]"],
3000
3475
  description: ["Whether this machine can ssh into workspaces, and what is missing if not."],
3001
- see: ["ssh setup"]
3476
+ see: ["ssh setup", "ssh keys"]
3002
3477
  },
3003
3478
  {
3004
3479
  name: "ssh setup",
@@ -3012,7 +3487,35 @@ var COMMANDS = [
3012
3487
  { flag: "--key <path>", text: "the public key to register" },
3013
3488
  { flag: "--host <hostname>", text: "override only the SSH hostname" }
3014
3489
  ],
3015
- see: ["ssh", "shell"]
3490
+ see: ["ssh", "ssh keys", "shell"]
3491
+ },
3492
+ {
3493
+ name: "ssh keys",
3494
+ section: "this machine",
3495
+ summary: "your registered workspace ssh keys, from every machine",
3496
+ synopsis: ["[--json]"],
3497
+ description: [
3498
+ "Every key your account registered with the workspace SSH gateway: fingerprint, name, algorithm and the day it was registered. The key this machine would offer is marked. The platform does not record when a key was last used."
3499
+ ],
3500
+ options: [{ flag: "--json", text: "the keys as JSON, with thisMachine on each" }],
3501
+ see: ["ssh keys remove", "ssh setup"]
3502
+ },
3503
+ {
3504
+ name: "ssh keys remove",
3505
+ section: "this machine",
3506
+ summary: "stop the workspace ssh gateway accepting one of your keys",
3507
+ synopsis: ["<fingerprint>"],
3508
+ description: [
3509
+ "Removes one of your keys by fingerprint; the SHA256: prefix is optional. The gateway looks a key up on every new connection, so the next connection offering it is refused. A connection already open stays open until it ends.",
3510
+ "Only your own keys are listed and removed. The key file and the ~/.ssh/config block on the machine that registered it stay; mend ssh setup there registers it again. Removing a member from the organization removes all of theirs, and Mend keeps retrying any the platform refused."
3511
+ ],
3512
+ examples: [
3513
+ {
3514
+ command: "mend ssh keys remove SHA256:Vn6v0P2dHq1n2a5aGQ6L7rKk8sWm0u3x1zYbTq9cE4o",
3515
+ text: "revoke a lost laptop's key"
3516
+ }
3517
+ ],
3518
+ see: ["ssh keys"]
3016
3519
  },
3017
3520
  {
3018
3521
  name: "accounts",
@@ -3038,7 +3541,7 @@ var COMMANDS = [
3038
3541
  summary: "remove the server, this machine's Mend files, or both",
3039
3542
  synopsis: ["[--all | --server | --home] [--yes]"],
3040
3543
  description: [
3041
- "Asks which scope to remove when none is given, prints exactly what will go, and requires the word delete before the server is touched. The server scope removes the local Compose installation: its containers, every volume it owns (repositories, worktrees, the database), its release image, and the private configuration with its generations and backups. The home scope revokes this terminal's device token, then removes cli.json, the workspace SSH key, and the managed block in ~/.ssh/config.",
3544
+ "Asks which scope to remove when none is given, prints exactly what will go, and requires the word delete before the server is touched. The server scope removes the local Compose installation: its containers, every volume it owns (repositories, worktrees, the database), its release image, and the private configuration with its generations and backups. The home scope asks the server to remove the workspace SSH key this machine registered and to revoke this terminal's device token, then removes cli.json, the workspace SSH key file, and the managed block in ~/.ssh/config. The key is found by its public half; a key the server refuses to remove, or one this machine cannot read, makes the uninstall exit 1 naming what may still be registered. The account's other keys and devices stay.",
3042
3545
  "Nothing else under the configuration directory is touched; a host-run store or keys root is listed and left in place. Workspace containers carry no label Mend can filter on, so they are named with the command that removes them, never removed."
3043
3546
  ],
3044
3547
  options: [
@@ -3447,7 +3950,7 @@ var writeBundle = (files, outPath, now) => {
3447
3950
  entries: entries.map((entry) => ({ path: entry.path, bytes: entry.content.length }))
3448
3951
  };
3449
3952
  };
3450
- var formatBytes = (bytes) => {
3953
+ var formatBytes2 = (bytes) => {
3451
3954
  if (bytes < 1024) return `${bytes} B`;
3452
3955
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} kB`;
3453
3956
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
@@ -3456,8 +3959,8 @@ var NOTICE = "Contains logs and configuration; secrets are redacted, but read it
3456
3959
  var bundleReport = (written) => {
3457
3960
  const width = Math.max(...written.entries.map((entry) => entry.path.length), 0);
3458
3961
  return [
3459
- `${written.path} \xB7 ${formatBytes(written.bytes)}`,
3460
- ...written.entries.map((entry) => ` ${entry.path.padEnd(width)} ${formatBytes(entry.bytes)}`),
3962
+ `${written.path} \xB7 ${formatBytes2(written.bytes)}`,
3963
+ ...written.entries.map((entry) => ` ${entry.path.padEnd(width)} ${formatBytes2(entry.bytes)}`),
3461
3964
  NOTICE
3462
3965
  ];
3463
3966
  };
@@ -4602,7 +5105,7 @@ var fetchedLines = (fetched) => {
4602
5105
  lines.push(`${dim(" switch to it")} git switch ${fetched.branch}`);
4603
5106
  return lines;
4604
5107
  };
4605
- var formatBytes2 = (bytes) => {
5108
+ var formatBytes3 = (bytes) => {
4606
5109
  if (bytes < 1024) return `${bytes} bytes`;
4607
5110
  const units = ["KiB", "MiB", "GiB"];
4608
5111
  let value = bytes / 1024;
@@ -4635,7 +5138,7 @@ var bundleRefusal = (downloaded) => {
4635
5138
  const size = numberField(body, "size");
4636
5139
  const limit = numberField(body, "limit");
4637
5140
  if (downloaded.status === 413 && size !== null && limit !== null) {
4638
- return `bundle not sent \xB7 ${formatBytes2(size)} (${size} bytes) \xB7 the server's limit is ${formatBytes2(limit)} (MEND_BUDGET_BUNDLE_BYTES) \xB7 nothing was fetched`;
5141
+ return `bundle not sent \xB7 ${formatBytes3(size)} (${size} bytes) \xB7 the server's limit is ${formatBytes3(limit)} (MEND_BUDGET_BUNDLE_BYTES) \xB7 nothing was fetched`;
4639
5142
  }
4640
5143
  return stringField2(body, "message") ?? `the bundle request answered ${downloaded.status}`;
4641
5144
  };
@@ -5030,7 +5533,10 @@ var modelCatalogLines = (catalogs, dim8 = (text) => text) => {
5030
5533
  }
5031
5534
  return lines;
5032
5535
  };
5033
- var modelCatalogJson = (catalogs) => JSON.stringify({ version: 1, harnesses: catalogs }, null, 2);
5536
+ var modelCatalogJson = (catalogs) => ({
5537
+ version: 1,
5538
+ harnesses: catalogs
5539
+ });
5034
5540
 
5035
5541
  // src/organization.ts
5036
5542
  import * as fs12 from "node:fs";
@@ -5667,21 +6173,39 @@ var piProfileLines = (scan) => {
5667
6173
 
5668
6174
  // src/run-scripts.ts
5669
6175
  var RUN_ARGV_MAX = 64;
6176
+ var RUN_ARGV_MAX_WORD_BYTES = 128 * 1024 - 1;
6177
+ var RUN_ARGV_MAX_TOTAL_BYTES = 1024 * 1024;
5670
6178
  var whitespaceName = (character) => character === "\n" || character === "\r" ? "a newline" : character === " " ? "a tab" : character === " " ? "a space" : "whitespace";
6179
+ var LONE_SURROGATE = /[\uD800-\uDFFF]/u;
6180
+ var utf8Bytes = (value) => Buffer.byteLength(value, "utf8");
5671
6181
  var runArgvIssue = (argv) => {
5672
6182
  if (argv.length > RUN_ARGV_MAX) {
5673
6183
  return `the command has ${argv.length} words and the platform takes at most ${RUN_ARGV_MAX} \xB7 put them in a script and run that`;
5674
6184
  }
6185
+ const program = argv[0] ?? "";
6186
+ if (program === "") return "the program is empty";
6187
+ if (program.trim() !== program) {
6188
+ const leading = program.trimStart() !== program;
6189
+ const edge = leading ? program.charAt(0) : program.charAt(program.length - 1);
6190
+ return `the program ${leading ? "starts" : "ends"} with ${whitespaceName(edge)} \xB7 trim it and run again`;
6191
+ }
6192
+ let total = 0;
5675
6193
  for (const [index, word] of argv.entries()) {
5676
6194
  const which = index === 0 ? "the program" : `argument ${index}`;
5677
- if (word === "") {
5678
- return `${which} is empty \xB7 the platform refuses empty arguments`;
6195
+ if (word.includes("\0")) {
6196
+ return `${which} contains a NUL byte, which no process argument can carry`;
6197
+ }
6198
+ if (LONE_SURROGATE.test(word)) {
6199
+ return `${which} is not well-formed Unicode (a lone surrogate)`;
5679
6200
  }
5680
- if (word.trim() !== word) {
5681
- const leading = word.trimStart() !== word;
5682
- const edge = leading ? word.charAt(0) : word.charAt(word.length - 1);
5683
- return `${which} ${leading ? "starts" : "ends"} with ${whitespaceName(edge)} \xB7 the platform refuses arguments with leading or trailing whitespace \xB7 trim it and run again`;
6201
+ const bytes = utf8Bytes(word);
6202
+ if (bytes > RUN_ARGV_MAX_WORD_BYTES) {
6203
+ return `${which} is ${bytes} bytes and the platform takes at most ${RUN_ARGV_MAX_WORD_BYTES} a word \xB7 put it in a script and run that`;
5684
6204
  }
6205
+ total += bytes;
6206
+ }
6207
+ if (total > RUN_ARGV_MAX_TOTAL_BYTES) {
6208
+ return `the command is ${total} bytes and the platform takes at most ${RUN_ARGV_MAX_TOTAL_BYTES} \xB7 put it in a script and run that`;
5685
6209
  }
5686
6210
  return null;
5687
6211
  };
@@ -5834,16 +6358,22 @@ var splitArgs = (args, valued, switches) => {
5834
6358
  return { values, on, session };
5835
6359
  };
5836
6360
  var parseLogsArgs = (args) => {
5837
- const split = splitArgs(args, ["--from", "--process"], ["--follow", "-f"]);
6361
+ const split = splitArgs(args, ["--from", "--process", "--service"], ["--follow", "-f"]);
5838
6362
  if ("error" in split) return split;
5839
6363
  const from = split.values.get("--from") ?? "0";
5840
6364
  if (!/^(0|[1-9]\d*)$/u.test(from)) return { error: "--from takes a record sequence, e.g. 0" };
6365
+ const process2 = split.values.get("--process") ?? null;
6366
+ const service = split.values.get("--service") ?? null;
6367
+ if (process2 !== null && service !== null) {
6368
+ return { error: "--process and --service each name one process; pass one of them" };
6369
+ }
5841
6370
  return {
5842
6371
  args: {
5843
6372
  session: split.session,
5844
6373
  follow: split.on.has("--follow") || split.on.has("-f"),
5845
6374
  from,
5846
- process: split.values.get("--process") ?? null
6375
+ process: process2,
6376
+ service
5847
6377
  }
5848
6378
  };
5849
6379
  };
@@ -5851,21 +6381,149 @@ var parseWaitArgs = (args) => {
5851
6381
  const split = splitArgs(args, ["--timeout", "--process"], ["--json"]);
5852
6382
  if ("error" in split) return split;
5853
6383
  const timeout = split.values.get("--timeout");
5854
- const seconds2 = timeout === void 0 ? null : Number(timeout);
5855
- if (seconds2 !== null && !(Number.isFinite(seconds2) && seconds2 > 0)) {
5856
- return { error: "--timeout takes a number of seconds above 0" };
5857
- }
6384
+ const timeoutMs = timeout === void 0 ? null : parseDuration(timeout);
6385
+ if (timeoutMs === null && timeout !== void 0) return { error: DURATION_ISSUE };
5858
6386
  return {
5859
6387
  args: {
5860
6388
  session: split.session,
5861
6389
  process: split.values.get("--process") ?? null,
5862
- timeoutMs: seconds2 === null ? null : Math.ceil(seconds2 * 1e3),
6390
+ timeoutMs,
5863
6391
  json: split.on.has("--json")
5864
6392
  }
5865
6393
  };
5866
6394
  };
6395
+ var DURATION_UNIT_MS = { s: 1e3, m: 6e4, h: 36e5 };
6396
+ var parseDuration = (text) => {
6397
+ const trimmed = text.trim();
6398
+ const unit = DURATION_UNIT_MS[trimmed.slice(-1)];
6399
+ 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;
6400
+ const ms = Math.ceil(amount * (unit ?? 1e3));
6401
+ return Number.isFinite(ms) && ms > 0 ? ms : null;
6402
+ };
6403
+ var DURATION_ISSUE = "--timeout takes a duration above 0: 90 or 90s, 5m, 1h";
6404
+ var durationLine = (ms) => {
6405
+ const seconds2 = Math.round(ms / 1e3);
6406
+ if (seconds2 < 60) return `${seconds2} s`;
6407
+ const minutes = Math.floor(seconds2 / 60);
6408
+ const rest = seconds2 % 60;
6409
+ if (minutes < 60) return rest === 0 ? `${minutes} min` : `${minutes} min ${rest} s`;
6410
+ const hours = Math.floor(minutes / 60);
6411
+ return minutes % 60 === 0 ? `${hours} h` : `${hours} h ${minutes % 60} min`;
6412
+ };
5867
6413
  var WAIT_TIMED_OUT = 124;
5868
6414
  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);
6415
+ var SERVICE_PROCESS_ENDED = 2;
6416
+ var SERVICE_WORKSPACE_ENDED = 3;
6417
+ var begunBy = (view, attempt2, startId) => attempt2.launchCorrelationId === serviceStartCorrelation(view.service.id, startId);
6418
+ var findStartAttempt = (services, target) => {
6419
+ for (const view of services) {
6420
+ if (view.service.sessionId !== target.sessionId) continue;
6421
+ const attempt2 = view.attempts.find(
6422
+ (candidate) => target.attemptId !== null ? candidate.id === target.attemptId : begunBy(view, candidate, target.startId)
6423
+ );
6424
+ if (attempt2 !== void 0) return { view, attempt: attempt2 };
6425
+ }
6426
+ return void 0;
6427
+ };
6428
+ var STAMP_KIND = serviceStartCorrelation("", "").split(":")[0] ?? "service-start";
6429
+ var answeredAttemptId = (view, startId) => {
6430
+ const own = view.attempts.find((attempt2) => begunBy(view, attempt2, startId));
6431
+ if (own !== void 0) return own.id;
6432
+ const stamps = view.attempts.some(
6433
+ (attempt2) => attempt2.launchCorrelationId?.startsWith(`${STAMP_KIND}:`) === true
6434
+ );
6435
+ return stamps ? null : view.service.currentAttemptId;
6436
+ };
6437
+ var timeOf = (iso) => {
6438
+ if (iso === void 0) return null;
6439
+ const at = Date.parse(iso);
6440
+ return Number.isFinite(at) ? at : null;
6441
+ };
6442
+ var answeredFor = (view, attempt2) => {
6443
+ const observation = view.latestObservation;
6444
+ if (view.service.currentAttemptId !== attempt2.id || view.currentForward === null) return false;
6445
+ if (observation === null || observation.forwardId !== view.currentForward.id) return false;
6446
+ if (observation.state !== "reachable") return false;
6447
+ const began = timeOf(attempt2.createdAt);
6448
+ const observed = timeOf(observation.lastObservedAt);
6449
+ return began === null || observed === null || observed >= began;
6450
+ };
6451
+ var serviceStartStateOf = (read, target) => {
6452
+ const found = findStartAttempt(read.services, target);
6453
+ if (found !== void 0 && found.attempt.exitedAt !== null) {
6454
+ return {
6455
+ kind: "process-ended",
6456
+ processId: found.attempt.id,
6457
+ status: found.attempt.status,
6458
+ exitCode: found.attempt.exitCode ?? null
6459
+ };
6460
+ }
6461
+ if (read.sessionStatus === null) return { kind: "workspace-ended", sessionStatus: null };
6462
+ if (found === void 0) return { kind: "starting", processId: target.attemptId };
6463
+ return answeredFor(found.view, found.attempt) ? { kind: "answered", processId: found.attempt.id, view: found.view } : { kind: "starting", processId: found.attempt.id };
6464
+ };
6465
+ var waitForServiceStart = async (options) => {
6466
+ const pollMs = options.pollMs ?? 2e3;
6467
+ const discoverWithinMs = options.unreachableAfterMs ?? 6e4;
6468
+ const since = options.now();
6469
+ let target = options.target;
6470
+ let last = null;
6471
+ for (; ; ) {
6472
+ const got = await readThrough(options.read, options, options.deadline);
6473
+ if (!got.done) return { kind: "timeout", last };
6474
+ const state = serviceStartStateOf(got.value, target);
6475
+ if (state.kind !== "starting") return state;
6476
+ last = state;
6477
+ if (state.processId === null) {
6478
+ if (options.now() - since >= discoverWithinMs) return { kind: "no-attempt" };
6479
+ } else if (target.attemptId === null) {
6480
+ target = { ...target, attemptId: state.processId };
6481
+ }
6482
+ options.onStarting?.(state);
6483
+ const left = options.deadline === null ? pollMs : Math.min(pollMs, options.deadline - options.now());
6484
+ if (left <= 0) return { kind: "timeout", last };
6485
+ await options.sleep(left);
6486
+ }
6487
+ };
6488
+ var readWithin = (read, clock2, deadline) => readThrough(read, clock2, deadline);
6489
+ var candidateLine = (view) => `${view.service.id} (session ${view.service.sessionId.slice(0, 8)})`;
6490
+ var pickServiceAttempt = (services, needle, sessionId) => {
6491
+ const inScope = services.filter(
6492
+ (view) => sessionId === null || view.service.sessionId === sessionId
6493
+ );
6494
+ const byId = inScope.filter((view) => view.service.id === needle);
6495
+ const byName = inScope.filter((view) => view.service.name === needle);
6496
+ const matches = byId.length > 0 ? byId : byName.length > 0 ? byName : inScope.filter((view) => view.service.id.startsWith(needle));
6497
+ const [chosen] = matches;
6498
+ if (chosen === void 0) {
6499
+ return {
6500
+ error: `no Service ${sessionId === null ? "" : "of this session "}is named "${needle}" or has an id starting with it`,
6501
+ named: false
6502
+ };
6503
+ }
6504
+ if (matches.length > 1) {
6505
+ return {
6506
+ error: `"${needle}" names ${matches.length} Services \xB7 name one by its id: ${matches.map(candidateLine).join(", ")}`,
6507
+ named: true
6508
+ };
6509
+ }
6510
+ const name = chosen.service.name;
6511
+ const attemptId = chosen.service.currentAttemptId;
6512
+ if (attemptId === null) {
6513
+ return {
6514
+ 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)`,
6515
+ named: true
6516
+ };
6517
+ }
6518
+ const attempt2 = chosen.attempts.find((candidate) => candidate.id === attemptId);
6519
+ if (attempt2 === void 0 || attempt2.sealantSessionId === null) {
6520
+ return {
6521
+ 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`,
6522
+ named: true
6523
+ };
6524
+ }
6525
+ return { service: chosen, processId: attemptId };
6526
+ };
5869
6527
 
5870
6528
  // src/secret-files.ts
5871
6529
  import * as path14 from "node:path";
@@ -6171,7 +6829,14 @@ var writeDurable = (file, content, mode = 384) => {
6171
6829
  }
6172
6830
  };
6173
6831
  var fileKeys = ["identity", "config", "env", "compose", "postgresInit"];
6174
- var optionalFileKeys = ["posture", "edge", "caddyfile"];
6832
+ var optionalFileKeys = [
6833
+ "posture",
6834
+ "edge",
6835
+ "caddyfile",
6836
+ "mirrors",
6837
+ "npmMirrorConf",
6838
+ "dockerMirrorGuard"
6839
+ ];
6175
6840
  var fileNames = {
6176
6841
  identity: "identity.env",
6177
6842
  config: "server.json",
@@ -6180,7 +6845,10 @@ var fileNames = {
6180
6845
  postgresInit: "postgres-init.sh",
6181
6846
  posture: "compose.posture.yaml",
6182
6847
  edge: "compose.edge.yaml",
6183
- caddyfile: "Caddyfile"
6848
+ caddyfile: "Caddyfile",
6849
+ mirrors: "compose.mirrors.yaml",
6850
+ npmMirrorConf: "npm-mirror.conf",
6851
+ dockerMirrorGuard: "docker-mirror-guard.sh"
6184
6852
  };
6185
6853
  var fileModes = {
6186
6854
  identity: 384,
@@ -6190,7 +6858,10 @@ var fileModes = {
6190
6858
  postgresInit: 493,
6191
6859
  posture: 384,
6192
6860
  edge: 384,
6193
- caddyfile: 420
6861
+ caddyfile: 420,
6862
+ mirrors: 384,
6863
+ npmMirrorConf: 420,
6864
+ dockerMirrorGuard: 420
6194
6865
  };
6195
6866
  var allFiles = (files) => [...fileKeys, ...optionalFileKeys].map((key) => [key, files[key]]);
6196
6867
  var parseLockOwner = (raw) => {
@@ -6294,6 +6965,9 @@ var readActive = (paths) => {
6294
6965
  const posture = optional("posture");
6295
6966
  const edge = optional("edge");
6296
6967
  const caddyfile = optional("caddyfile");
6968
+ const mirrors = optional("mirrors");
6969
+ const npmMirrorConf = optional("npmMirrorConf");
6970
+ const dockerMirrorGuard = optional("dockerMirrorGuard");
6297
6971
  const files = {
6298
6972
  identity: fs14.readFileSync(path15.join(directory, fileNames.identity), "utf8"),
6299
6973
  config: fs14.readFileSync(path15.join(directory, fileNames.config), "utf8"),
@@ -6302,7 +6976,10 @@ var readActive = (paths) => {
6302
6976
  postgresInit: fs14.readFileSync(path15.join(directory, fileNames.postgresInit), "utf8"),
6303
6977
  ...posture === void 0 ? {} : { posture },
6304
6978
  ...edge === void 0 ? {} : { edge },
6305
- ...caddyfile === void 0 ? {} : { caddyfile }
6979
+ ...caddyfile === void 0 ? {} : { caddyfile },
6980
+ ...mirrors === void 0 ? {} : { mirrors },
6981
+ ...npmMirrorConf === void 0 ? {} : { npmMirrorConf },
6982
+ ...dockerMirrorGuard === void 0 ? {} : { dockerMirrorGuard }
6306
6983
  };
6307
6984
  if (readIdentity(paths) !== files.identity) {
6308
6985
  throw new ServerStoreError(
@@ -6852,7 +7529,28 @@ var SETUP_FLAGS = /* @__PURE__ */ new Set([
6852
7529
  "--no-edge",
6853
7530
  "--exposure",
6854
7531
  "--tenancy",
6855
- "--declare"
7532
+ "--declare",
7533
+ "--npm-mirror",
7534
+ "--no-npm-mirror",
7535
+ "--npm-mirror-max-size",
7536
+ "--docker-mirror",
7537
+ "--no-docker-mirror",
7538
+ "--docker-mirror-max-size",
7539
+ "--docker-hub-username",
7540
+ "--docker-hub-token-stdin",
7541
+ "--docker-hub-public-only",
7542
+ "--no-docker-hub-login"
7543
+ ]);
7544
+ var SWITCHES = /* @__PURE__ */ new Set([
7545
+ "--offline",
7546
+ "--no-edge",
7547
+ "--npm-mirror",
7548
+ "--no-npm-mirror",
7549
+ "--docker-mirror",
7550
+ "--no-docker-mirror",
7551
+ "--docker-hub-token-stdin",
7552
+ "--docker-hub-public-only",
7553
+ "--no-docker-hub-login"
6856
7554
  ]);
6857
7555
  var parseExposure = (value) => {
6858
7556
  if (!isExposure(value))
@@ -6895,7 +7593,7 @@ var parseSetupOptions = (args) => {
6895
7593
  if (flag === void 0) continue;
6896
7594
  if (!flag.startsWith("--")) throw setupError(`Unexpected server setup argument "${flag}".`);
6897
7595
  if (!SETUP_FLAGS.has(flag)) throw setupError(`Unknown server setup option "${flag}".`);
6898
- if (flag === "--offline" || flag === "--no-edge") {
7596
+ if (SWITCHES.has(flag)) {
6899
7597
  if (values.has(flag)) throw setupError(`${flag} may be supplied only once.`);
6900
7598
  values.set(flag, ["true"]);
6901
7599
  continue;
@@ -6920,6 +7618,51 @@ var parseSetupOptions = (args) => {
6920
7618
  const declared = values.get("--declare");
6921
7619
  if (edge !== void 0 && values.has("--no-edge"))
6922
7620
  throw setupError("--edge and --no-edge contradict each other.");
7621
+ const pair = (on, off) => {
7622
+ if (values.has(on) && values.has(off))
7623
+ throw setupError(`${on} and ${off} contradict each other.`);
7624
+ return values.has(on) ? true : values.has(off) ? false : void 0;
7625
+ };
7626
+ const npmMirror = pair("--npm-mirror", "--no-npm-mirror");
7627
+ const dockerMirror = pair("--docker-mirror", "--no-docker-mirror");
7628
+ const maxSize = flagValue3("--npm-mirror-max-size");
7629
+ const npmMirrorMaxSize = maxSize === void 0 ? void 0 : parseMirrorSize(maxSize);
7630
+ if (npmMirrorMaxSize === null)
7631
+ throw setupError(
7632
+ `--npm-mirror-max-size must be a whole number of gibibytes or mebibytes, at least 1g, such as 20g or 1536m, not "${maxSize ?? ""}".`
7633
+ );
7634
+ if (npmMirrorMaxSize !== void 0 && npmMirror === false)
7635
+ throw setupError("--npm-mirror-max-size and --no-npm-mirror contradict each other.");
7636
+ const dockerMaxSize = flagValue3("--docker-mirror-max-size");
7637
+ const dockerMirrorMaxSize = dockerMaxSize === void 0 ? void 0 : parseMirrorSize(dockerMaxSize);
7638
+ if (dockerMirrorMaxSize === null)
7639
+ throw setupError(
7640
+ `--docker-mirror-max-size must be a whole number of gibibytes or mebibytes, at least 1g, such as 40g, not "${dockerMaxSize ?? ""}".`
7641
+ );
7642
+ if (dockerMirrorMaxSize !== void 0 && dockerMirror === false)
7643
+ throw setupError("--docker-mirror-max-size and --no-docker-mirror contradict each other.");
7644
+ const dockerHubUsername = flagValue3("--docker-hub-username");
7645
+ const dockerHubTokenStdin = values.has("--docker-hub-token-stdin");
7646
+ const noDockerHubLogin = values.has("--no-docker-hub-login");
7647
+ if (dockerHubUsername === void 0 !== !dockerHubTokenStdin)
7648
+ throw setupError(
7649
+ "--docker-hub-username and --docker-hub-token-stdin go together: the user name, and the access token on standard input."
7650
+ );
7651
+ if (dockerHubUsername !== void 0 && !isDockerHubCredential(dockerHubUsername))
7652
+ throw setupError(`--docker-hub-username "${dockerHubUsername}" is not a Docker Hub user name.`);
7653
+ const dockerHubPublicOnly = values.has("--docker-hub-public-only");
7654
+ if (dockerHubPublicOnly && dockerHubUsername === void 0)
7655
+ throw setupError(
7656
+ "--docker-hub-public-only goes with --docker-hub-username and --docker-hub-token-stdin: it states what the token you give can read."
7657
+ );
7658
+ if (dockerHubUsername !== void 0 && !dockerHubPublicOnly)
7659
+ throw setupError(
7660
+ `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.`
7661
+ );
7662
+ if (dockerHubUsername !== void 0 && (noDockerHubLogin || dockerMirror === false))
7663
+ throw setupError(
7664
+ `--docker-hub-username and ${noDockerHubLogin ? "--no-docker-hub-login" : "--no-docker-mirror"} contradict each other.`
7665
+ );
6923
7666
  return {
6924
7667
  context: flagValue3("--context"),
6925
7668
  version: version === void 0 ? void 0 : parseVersion(version),
@@ -6936,9 +7679,60 @@ var parseSetupOptions = (args) => {
6936
7679
  noEdge: values.has("--no-edge"),
6937
7680
  exposure: exposure === void 0 ? void 0 : parseExposure(exposure),
6938
7681
  tenancy: tenancy === void 0 ? void 0 : parseTenancy(tenancy),
6939
- declared: declared === void 0 ? void 0 : parseDeclared(declared)
7682
+ declared: declared === void 0 ? void 0 : parseDeclared(declared),
7683
+ npmMirror,
7684
+ npmMirrorMaxSize,
7685
+ dockerMirror,
7686
+ dockerMirrorMaxSize,
7687
+ dockerHubUsername,
7688
+ dockerHubTokenStdin,
7689
+ noDockerHubLogin
7690
+ };
7691
+ };
7692
+ var resolveMirrors = (saved, options) => {
7693
+ const base = saved ?? DEFAULT_MIRRORS;
7694
+ const npmOn = options.npmMirror ?? (options.npmMirrorMaxSize !== void 0 || base.npm !== null);
7695
+ const dockerOn = options.dockerMirror ?? (options.dockerHubUsername !== void 0 || options.dockerMirrorMaxSize !== void 0 || base.docker !== null);
7696
+ const upstreamUser = options.dockerHubUsername ?? (options.noDockerHubLogin ? void 0 : dockerMirrorLogin(base.docker));
7697
+ const dockerMaxSize = options.dockerMirrorMaxSize ?? base.docker?.maxSize ?? DEFAULT_DOCKER_MIRROR_MAX_SIZE;
7698
+ return {
7699
+ npm: npmOn ? {
7700
+ maxSize: options.npmMirrorMaxSize ?? base.npm?.maxSize ?? DEFAULT_NPM_MIRROR_MAX_SIZE
7701
+ } : null,
7702
+ docker: !dockerOn ? null : upstreamUser === void 0 ? { maxSize: dockerMaxSize } : { maxSize: dockerMaxSize, upstreamUser, upstreamPublicOnly: true }
6940
7703
  };
6941
7704
  };
7705
+ var parseMirrorsField = (value) => {
7706
+ const fields = ownFields(value);
7707
+ const corrupt = () => setupError(
7708
+ "Server config is corrupt: mirrors must hold npm and docker, each an object or null."
7709
+ );
7710
+ if (fields === null || !fields.has("npm") || !fields.has("docker")) throw corrupt();
7711
+ const npm = fields.get("npm");
7712
+ const docker2 = fields.get("docker");
7713
+ let parsedNpm = null;
7714
+ if (npm !== null) {
7715
+ const maxSize = ownFields(npm)?.get("maxSize");
7716
+ if (typeof maxSize !== "string" || parseMirrorSize(maxSize) !== maxSize) throw corrupt();
7717
+ parsedNpm = { maxSize };
7718
+ }
7719
+ let parsedDocker = null;
7720
+ if (docker2 !== null) {
7721
+ const dockerFields = ownFields(docker2);
7722
+ if (dockerFields === null) throw corrupt();
7723
+ const maxSize = dockerFields.get("maxSize");
7724
+ if (typeof maxSize !== "string" || parseMirrorSize(maxSize) !== maxSize) throw corrupt();
7725
+ const user = dockerFields.get("upstreamUser");
7726
+ if (user !== void 0 && (typeof user !== "string" || !isDockerHubCredential(user)))
7727
+ throw corrupt();
7728
+ if (user !== void 0 && dockerFields.get("upstreamPublicOnly") !== true)
7729
+ throw setupError(
7730
+ "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."
7731
+ );
7732
+ parsedDocker = user === void 0 ? { maxSize } : { maxSize, upstreamUser: user, upstreamPublicOnly: true };
7733
+ }
7734
+ return { npm: parsedNpm, docker: parsedDocker };
7735
+ };
6942
7736
  var parseUrl = (input, label) => {
6943
7737
  try {
6944
7738
  return new URL(input);
@@ -7137,7 +7931,8 @@ var parseServerConfig = (raw) => {
7137
7931
  ...edgeHost === void 0 ? {} : { edgeHost },
7138
7932
  ...exposure === void 0 ? {} : { exposure },
7139
7933
  ...tenancy === void 0 ? {} : { tenancy },
7140
- ...declared.length === 0 ? {} : { declared }
7934
+ ...declared.length === 0 ? {} : { declared },
7935
+ ...fields.has("mirrors") ? { mirrors: parseMirrorsField(fields.get("mirrors")) } : {}
7141
7936
  };
7142
7937
  if (config.schemaVersion !== CONFIG_SCHEMA_VERSION || config.assetContract !== ASSET_CONTRACT && !LEGACY_ASSET_CONTRACTS.has(config.assetContract)) {
7143
7938
  throw setupError(
@@ -7164,7 +7959,14 @@ var parseServerConfig = (raw) => {
7164
7959
  noEdge: false,
7165
7960
  exposure: config.exposure,
7166
7961
  tenancy: config.tenancy,
7167
- declared: config.declared
7962
+ declared: config.declared,
7963
+ npmMirror: void 0,
7964
+ npmMirrorMaxSize: void 0,
7965
+ dockerMirror: void 0,
7966
+ dockerMirrorMaxSize: void 0,
7967
+ dockerHubUsername: void 0,
7968
+ dockerHubTokenStdin: false,
7969
+ noDockerHubLogin: false
7168
7970
  });
7169
7971
  return config;
7170
7972
  };
@@ -7195,8 +7997,12 @@ var parseSecrets = (raw) => {
7195
7997
  betterAuthSecret: envValue(fields, "BETTER_AUTH_SECRET"),
7196
7998
  sealantCredentialsKey: envValue(fields, "SEALANT_CREDENTIALS_KEY"),
7197
7999
  sealantServiceKey: envValue(fields, "SEALANT_SERVICE_KEY"),
7198
- workspaceSshGatewayToken: envValue(fields, "WORKSPACE_SSH_GATEWAY_TOKEN")
8000
+ workspaceSshGatewayToken: envValue(fields, "WORKSPACE_SSH_GATEWAY_TOKEN"),
8001
+ ...fields.has("MEND_DOCKER_HUB_TOKEN") ? { dockerHubToken: envValue(fields, "MEND_DOCKER_HUB_TOKEN") } : {}
7199
8002
  };
8003
+ if (secrets.dockerHubToken !== void 0 && !isDockerHubCredential(secrets.dockerHubToken)) {
8004
+ throw setupError("Server secrets are corrupt: MEND_DOCKER_HUB_TOKEN has an invalid value.");
8005
+ }
7200
8006
  if (!/^slt_svc_[0-9a-f]{64}$/.test(secrets.sealantServiceKey)) {
7201
8007
  throw setupError("Server secrets are corrupt: SEALANT_SERVICE_KEY has an invalid value.");
7202
8008
  }
@@ -7523,6 +8329,9 @@ var renderSecrets = (secrets, config) => {
7523
8329
  "MEND_STORE_VOLUME_NAME=mend-store",
7524
8330
  "MEND_CONTROL_VOLUME_NAME=mend-control",
7525
8331
  ...renderGarage(secrets, config),
8332
+ // The mirrors' size cap and Docker Hub login (server-mirrors.ts); the token reaches only the
8333
+ // Docker mirror's container.
8334
+ ...mirrorsEnvLines(config.mirrors, secrets.dockerHubToken),
7526
8335
  `DOCKER_SOCKET_PATH=${config.dockerSocket}`,
7527
8336
  ""
7528
8337
  ].join("\n");
@@ -7573,6 +8382,47 @@ var validateOverlays = (config, files) => {
7573
8382
  }
7574
8383
  return edgeImageOf(files.edge);
7575
8384
  };
8385
+ var validateMirrors = (config, secrets, files) => {
8386
+ const mirrors = config.mirrors;
8387
+ if (runsMirrors(mirrors) !== (files.mirrors !== void 0)) {
8388
+ throw setupError(
8389
+ `Server generation is corrupt: ${MIRRORS_COMPOSE_FILE} does not match the persisted server config.`
8390
+ );
8391
+ }
8392
+ if ((mirrors !== void 0 && mirrors.npm !== null) !== (files.npmMirrorConf !== void 0)) {
8393
+ throw setupError(
8394
+ `Server generation is corrupt: ${NPM_MIRROR_CONF_NAME} does not match the persisted server config.`
8395
+ );
8396
+ }
8397
+ if (dockerMirrorLogin(mirrors?.docker ?? null) === void 0 !== (secrets.dockerHubToken === void 0)) {
8398
+ throw setupError(
8399
+ "Server secrets are corrupt: a Docker Hub login needs both MEND_DOCKER_HUB_USERNAME and MEND_DOCKER_HUB_TOKEN."
8400
+ );
8401
+ }
8402
+ if (files.mirrors === void 0) return [];
8403
+ for (const service of mirrorServices(mirrors)) {
8404
+ if (!files.mirrors.includes(`
8405
+ ${service}:
8406
+ `))
8407
+ throw setupError(
8408
+ `Server generation is corrupt: ${MIRRORS_COMPOSE_FILE} does not run the ${service} its config declares.`
8409
+ );
8410
+ }
8411
+ if ((mirrors !== void 0 && mirrors.docker !== null) !== (files.dockerMirrorGuard !== void 0)) {
8412
+ throw setupError(
8413
+ `Server generation is corrupt: ${DOCKER_MIRROR_GUARD_NAME} does not match the persisted server config.`
8414
+ );
8415
+ }
8416
+ if (files.dockerMirrorGuard !== void 0 && !files.dockerMirrorGuard.includes("registry serve /etc/distribution/config.yml"))
8417
+ throw setupError(
8418
+ `Server generation is corrupt: ${DOCKER_MIRROR_GUARD_NAME} is not the Docker mirror's guard.`
8419
+ );
8420
+ if (files.npmMirrorConf !== void 0 && !files.npmMirrorConf.includes("proxy_cache npm;"))
8421
+ throw setupError(
8422
+ `Server generation is corrupt: ${NPM_MIRROR_CONF_NAME} is not the npm mirror's configuration.`
8423
+ );
8424
+ return mirrorImagesOf(files.mirrors);
8425
+ };
7576
8426
  var readServerInstallation = (store) => {
7577
8427
  try {
7578
8428
  const generation = storeValue(store.readActive());
@@ -7587,12 +8437,14 @@ var readServerInstallation = (store) => {
7587
8437
  validateComposeAsset(generation.files.compose);
7588
8438
  validatePostgresAsset(generation.files.postgresInit);
7589
8439
  const edgeImage = validateOverlays(config, generation.files);
8440
+ const mirrorImages = validateMirrors(config, secrets, generation.files);
7590
8441
  return {
7591
8442
  _tag: "ok",
7592
8443
  value: {
7593
8444
  config,
7594
8445
  directory: generation.directory,
7595
- ...edgeImage === void 0 ? {} : { edgeImage }
8446
+ ...edgeImage === void 0 ? {} : { edgeImage },
8447
+ ...mirrorImages.length === 0 ? {} : { mirrorImages }
7596
8448
  }
7597
8449
  };
7598
8450
  } catch (cause) {
@@ -7623,7 +8475,10 @@ var readServerInstallationFacts = async (configDir) => {
7623
8475
  overlays: [
7624
8476
  ...generation.files.edge === void 0 ? [] : [{ name: "compose.edge.yaml", content: generation.files.edge }],
7625
8477
  ...generation.files.caddyfile === void 0 ? [] : [{ name: "Caddyfile", content: generation.files.caddyfile }],
7626
- ...generation.files.posture === void 0 ? [] : [{ name: "compose.posture.yaml", content: generation.files.posture }]
8478
+ ...generation.files.posture === void 0 ? [] : [{ name: "compose.posture.yaml", content: generation.files.posture }],
8479
+ ...generation.files.mirrors === void 0 ? [] : [{ name: MIRRORS_COMPOSE_FILE, content: generation.files.mirrors }],
8480
+ ...generation.files.npmMirrorConf === void 0 ? [] : [{ name: NPM_MIRROR_CONF_NAME, content: generation.files.npmMirrorConf }],
8481
+ ...generation.files.dockerMirrorGuard === void 0 ? [] : [{ name: DOCKER_MIRROR_GUARD_NAME, content: generation.files.dockerMirrorGuard }]
7627
8482
  ],
7628
8483
  envKeys: envKeyNames(generation.files.env)
7629
8484
  };
@@ -7635,6 +8490,7 @@ var readServerInstallationFacts = async (configDir) => {
7635
8490
  };
7636
8491
  var generationFiles = (config, secrets, assets, identity = renderIdentity(secrets)) => {
7637
8492
  const posture = renderPostureOverlay(config);
8493
+ const mirrors = renderMirrorsOverlay(config.mirrors);
7638
8494
  return {
7639
8495
  identity,
7640
8496
  config: `${JSON.stringify(config, null, 2)}
@@ -7642,7 +8498,10 @@ var generationFiles = (config, secrets, assets, identity = renderIdentity(secret
7642
8498
  env: renderSecrets(secrets, config),
7643
8499
  ...assets,
7644
8500
  ...posture === void 0 ? {} : { posture },
7645
- ...config.edgeHost === void 0 ? {} : { edge: EDGE_COMPOSE_OVERLAY, caddyfile: EDGE_CADDYFILE }
8501
+ ...config.edgeHost === void 0 ? {} : { edge: EDGE_COMPOSE_OVERLAY, caddyfile: EDGE_CADDYFILE },
8502
+ ...mirrors === void 0 ? {} : { mirrors },
8503
+ ...config.mirrors === void 0 || config.mirrors.npm === null ? {} : { npmMirrorConf: NPM_MIRROR_CONF },
8504
+ ...config.mirrors === void 0 || config.mirrors.docker === null ? {} : { dockerMirrorGuard: DOCKER_MIRROR_GUARD }
7646
8505
  };
7647
8506
  };
7648
8507
  var persistSetup = (store, config, secrets, assets) => storeValue(store.prepare(generationFiles(config, secrets, assets)));
@@ -7811,6 +8670,17 @@ var checkLocalImages = async (runtime, installation, policy = "local") => {
7811
8670
  if (edge.status !== 0)
7812
8671
  throw commandFailure(`Preload ${installation.edgeImage} before continuing`, edge);
7813
8672
  }
8673
+ for (const mirrorImage of installation.mirrorImages ?? []) {
8674
+ const mirror = await inspectImage(
8675
+ runtime,
8676
+ config.dockerContext,
8677
+ mirrorImage,
8678
+ "{{.Id}}",
8679
+ policy
8680
+ );
8681
+ if (mirror.status !== 0)
8682
+ throw commandFailure(`Preload ${mirrorImage} before continuing`, mirror);
8683
+ }
7814
8684
  };
7815
8685
  var checkComposeImages = async (runtime, installation) => {
7816
8686
  const output = await runtime.run(
@@ -7823,11 +8693,12 @@ var checkComposeImages = async (runtime, installation) => {
7823
8693
  `ghcr.io/sealant-sh/mend:${installation.config.serverVersion}`,
7824
8694
  "postgres:17-alpine",
7825
8695
  ...installation.config.bucket === "garage" ? [GARAGE_IMAGE] : [],
7826
- ...installation.edgeImage === void 0 ? [] : [installation.edgeImage]
8696
+ ...installation.edgeImage === void 0 ? [] : [installation.edgeImage],
8697
+ ...installation.mirrorImages ?? []
7827
8698
  ].toSorted();
7828
8699
  if (images.join("\n") !== expected.join("\n")) {
7829
8700
  throw setupError(
7830
- `Compose must use only the canonical pinned Mend image, official postgres:17-alpine${installation.config.bucket === "garage" ? ` and ${GARAGE_IMAGE}` : ""}${installation.edgeImage === void 0 ? "" : ` and the edge's ${installation.edgeImage}`}.`
8701
+ `Compose must use only the canonical pinned Mend image, official postgres:17-alpine${installation.config.bucket === "garage" ? ` and ${GARAGE_IMAGE}` : ""}${installation.edgeImage === void 0 ? "" : ` and the edge's ${installation.edgeImage}`}${installation.mirrorImages === void 0 ? "" : ` and the mirrors' ${installation.mirrorImages.join(", ")}`}.`
7831
8702
  );
7832
8703
  }
7833
8704
  };
@@ -7874,8 +8745,16 @@ var initGarage = async (runtime, installation, secrets) => {
7874
8745
  var startingNotice = (runtime, version) => runtime.writeLine(
7875
8746
  `Starting Mend ${version} containers; Docker waits up to ${serverProcessDeadlines.composeWaitSeconds}s for them to report healthy`
7876
8747
  );
7877
- var removeStrayEdge = async (runtime, installation) => {
7878
- if (installation.config.edgeHost !== void 0) return;
8748
+ var strayServices = (config) => [
8749
+ ...config.edgeHost === void 0 ? [{ service: "edge", label: "edge", holds: "It may still hold 80 and 443." }] : [],
8750
+ ...config.mirrors?.npm === null ? [{ service: "npm-mirror", label: "npm mirror", holds: "It keeps running beside Mend." }] : [],
8751
+ ...config.mirrors?.docker === null ? [{ service: "docker-mirror", label: "Docker mirror", holds: "It keeps running beside Mend." }] : []
8752
+ ];
8753
+ var removeStrayServices = async (runtime, installation) => {
8754
+ for (const stray of strayServices(installation.config))
8755
+ await removeStrayService(runtime, installation, stray);
8756
+ };
8757
+ var removeStrayService = async (runtime, installation, stray) => {
7879
8758
  const context = installation.config.dockerContext;
7880
8759
  const listed = await runtime.run("docker", [
7881
8760
  "--context",
@@ -7886,13 +8765,13 @@ var removeStrayEdge = async (runtime, installation) => {
7886
8765
  "--filter",
7887
8766
  "label=com.docker.compose.project=mend",
7888
8767
  "--filter",
7889
- "label=com.docker.compose.service=edge",
8768
+ `label=com.docker.compose.service=${stray.service}`,
7890
8769
  "--format",
7891
8770
  '{{.Names}} {{.Label "com.docker.compose.project.working_dir"}}'
7892
8771
  ]);
7893
8772
  if (listed.status !== 0 || listed.error !== void 0) {
7894
8773
  runtime.writeLine(
7895
- `Could not list this project's containers: ${outputDetail(listed)}. An edge container left by an earlier generation, if any, stays until the next start. mend server status shows it.`
8774
+ `Could not list this project's containers: ${outputDetail(listed)}. An ${stray.label} container left by an earlier generation, if any, stays until the next start. mend server status shows it.`
7896
8775
  );
7897
8776
  return;
7898
8777
  }
@@ -7908,9 +8787,10 @@ var removeStrayEdge = async (runtime, installation) => {
7908
8787
  const [name = "", workingDir = ""] = line.split(" ");
7909
8788
  return { name, workingDir };
7910
8789
  }).filter((row) => row.name !== "");
8790
+ const label = stray.label.charAt(0).toUpperCase() + stray.label.slice(1);
7911
8791
  for (const row of rows.filter((candidate) => !own(candidate.workingDir))) {
7912
8792
  runtime.writeLine(
7913
- `Edge container ${row.name} was started from ${row.workingDir || "an unknown directory"}, not from this installation's generations. It stays.`
8793
+ `${label} container ${row.name} was started from ${row.workingDir || "an unknown directory"}, not from this installation's generations. It stays.`
7914
8794
  );
7915
8795
  }
7916
8796
  const names = rows.filter((row) => own(row.workingDir)).map((row) => row.name);
@@ -7925,16 +8805,16 @@ var removeStrayEdge = async (runtime, installation) => {
7925
8805
  ]);
7926
8806
  if (removed.status !== 0 || removed.error !== void 0) {
7927
8807
  runtime.writeLine(
7928
- `Could not remove the edge container ${names.join(", ")}: ${outputDetail(removed)}. It may still hold 80 and 443. To remove it: docker --context ${context} container rm --force ${names.join(" ")}`
8808
+ `Could not remove the ${stray.label} container ${names.join(", ")}: ${outputDetail(removed)}. ${stray.holds} To remove it: docker --context ${context} container rm --force ${names.join(" ")}`
7929
8809
  );
7930
8810
  return;
7931
8811
  }
7932
8812
  runtime.writeLine(
7933
- `Removed the edge container ${names.join(", ")}, which this generation does not run.`
8813
+ `Removed the ${stray.label} container ${names.join(", ")}, which this generation does not run.`
7934
8814
  );
7935
8815
  };
7936
8816
  var startCompose = async (runtime, installation) => {
7937
- await removeStrayEdge(runtime, installation);
8817
+ await removeStrayServices(runtime, installation);
7938
8818
  startingNotice(runtime, installation.config.serverVersion);
7939
8819
  const compose = await runtime.run(
7940
8820
  "docker",
@@ -7974,6 +8854,48 @@ var sshPublicationLine = async (runtime, config) => {
7974
8854
  return `Workspace SSH is published on ${published}. From this machine: ${observed}. Who else reaches it is up to the network and its firewall; mend operator exposure reports it as workspace-ssh.`;
7975
8855
  };
7976
8856
  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}.`}`;
8857
+ var resolveDockerHubToken = async (runtime, options, mirrors, store) => {
8858
+ if (dockerMirrorLogin(mirrors.docker) === void 0) return void 0;
8859
+ if (options.dockerHubTokenStdin) {
8860
+ if (runtime.readStdin === void 0)
8861
+ throw setupError("--docker-hub-token-stdin needs standard input, and none is available.");
8862
+ const token = (await runtime.readStdin()).trim();
8863
+ if (!isDockerHubCredential(token))
8864
+ throw setupError(
8865
+ "--docker-hub-token-stdin read no Docker Hub access token: pipe the token alone, such as a personal access token (dckr_pat_\u2026)."
8866
+ );
8867
+ return token;
8868
+ }
8869
+ const active = storeValue(store.readActive());
8870
+ return active === null ? void 0 : parseSecrets(active.files.env).dockerHubToken;
8871
+ };
8872
+ var mirrorsChangedLines = (before, config) => {
8873
+ const after = config.mirrors;
8874
+ if (after === void 0) return [];
8875
+ const lines = [];
8876
+ const said = (name, was, is, volume, reach) => {
8877
+ if (is && !was) lines.push(`The ${name} runs on this install. ${reach}`);
8878
+ if (was && !is)
8879
+ lines.push(
8880
+ `The ${name} is off. Its cache stays until you remove it: docker --context ${config.dockerContext} volume rm ${volume}`
8881
+ );
8882
+ };
8883
+ said(
8884
+ "npm mirror",
8885
+ before !== void 0 && before.npm !== null,
8886
+ after.npm !== null,
8887
+ "mend_mend-npm-mirror",
8888
+ `New sessions install npm packages through it, capped at ${after.npm?.maxSize ?? ""}.`
8889
+ );
8890
+ said(
8891
+ "Docker mirror",
8892
+ before !== void 0 && before.docker !== null,
8893
+ after.docker !== null,
8894
+ "mend_mend-docker-mirror",
8895
+ `New sessions' Docker daemons pull Docker Hub images through it (${DOCKER_MIRROR_CONTAINER}).`
8896
+ );
8897
+ return lines;
8898
+ };
7977
8899
  var setupServer = async (args, runtime, store) => {
7978
8900
  if (runtime.platform !== "linux" && runtime.platform !== "darwin") {
7979
8901
  throw setupError(`mend server setup supports Linux and macOS, not ${runtime.platform}.`);
@@ -7999,6 +8921,7 @@ var setupServer = async (args, runtime, store) => {
7999
8921
  const operatingSystem = await checkDocker(runtime, selectedContext.name);
8000
8922
  runtime.writeLine(`Using Docker context "${selectedContext.name}" (${selectedContext.endpoint})`);
8001
8923
  const serverVersion = await resolveServerVersion(runtime, options, existing?.config ?? null);
8924
+ const mirrors = resolveMirrors(existing?.config.mirrors, options);
8002
8925
  const configWithoutBucket = {
8003
8926
  schemaVersion: CONFIG_SCHEMA_VERSION,
8004
8927
  assetContract: ASSET_CONTRACT,
@@ -8009,8 +8932,10 @@ var setupServer = async (args, runtime, store) => {
8009
8932
  ...selectedContext,
8010
8933
  operatingSystem
8011
8934
  }),
8012
- ...validateExposure(existing?.config ?? null, options)
8935
+ ...validateExposure(existing?.config ?? null, options),
8936
+ mirrors
8013
8937
  };
8938
+ const dockerHubToken = await resolveDockerHubToken(runtime, options, mirrors, store);
8014
8939
  const assets = await resolveAssets(runtime, serverVersion, existing, store, options);
8015
8940
  checkSshBindAsset(configWithoutBucket, assets.compose);
8016
8941
  const bucket = composeBucket(assets.compose);
@@ -8036,12 +8961,16 @@ var setupServer = async (args, runtime, store) => {
8036
8961
  `A fresh install cannot start with the edge or as public: until the first account exists, registration is open to whoever reaches the origin first, and the server refuses MEND_EXPOSURE=public without an operator account. Run mend server setup without --edge and --exposure public, create the first account at http://localhost:${config.appPort}, then run mend server setup --edge <host> and declare the posture.`
8037
8962
  );
8038
8963
  }
8039
- const secrets = savedSecrets ?? createSecrets(runtime);
8964
+ const secrets = {
8965
+ ...savedSecrets ?? createSecrets(runtime),
8966
+ ...dockerHubToken === void 0 ? {} : { dockerHubToken }
8967
+ };
8040
8968
  const generation = persistSetup(store, config, secrets, assets);
8041
8969
  const installation = {
8042
8970
  directory: generation.directory,
8043
8971
  config,
8044
- ...generation.files.edge === void 0 ? {} : { edgeImage: edgeImageOf(generation.files.edge) }
8972
+ ...generation.files.edge === void 0 ? {} : { edgeImage: edgeImageOf(generation.files.edge) },
8973
+ ...generation.files.mirrors === void 0 ? {} : { mirrorImages: mirrorImagesOf(generation.files.mirrors) }
8045
8974
  };
8046
8975
  await checkComposeImages(runtime, installation);
8047
8976
  const ownership2 = await claimServerDockerVolumes(runtime, {
@@ -8070,6 +8999,7 @@ var setupServer = async (args, runtime, store) => {
8070
8999
  `The edge for ${existing.config.edgeHost} is gone. Its certificate volumes stay until you remove them: docker --context ${config.dockerContext} volume rm mend_mend-edge-data mend_mend-edge-config`
8071
9000
  );
8072
9001
  }
9002
+ for (const line of mirrorsChangedLines(existing?.config.mirrors, config)) runtime.writeLine(line);
8073
9003
  runtime.writeLine(
8074
9004
  `Open ${config.appUrl}, create the first account, then run: mend login --url ${config.appUrl}`
8075
9005
  );
@@ -8134,7 +9064,7 @@ var interruptionNotice = (runtime) => runtime.writeLine(
8134
9064
  "Connections will be interrupted. Workspace containers and data are retained, but active work can lose connectivity and may need reconnection. Mend does not stop workspace containers."
8135
9065
  );
8136
9066
  var startInstallation = async (runtime, installation, secrets) => {
8137
- await removeStrayEdge(runtime, installation);
9067
+ await removeStrayServices(runtime, installation);
8138
9068
  startingNotice(runtime, installation.config.serverVersion);
8139
9069
  await composeCommand(runtime, installation, [
8140
9070
  "up",
@@ -8182,7 +9112,7 @@ var parseUpgradeOptions = (args) => {
8182
9112
  );
8183
9113
  return { ...options, fromPreview, keepBackups };
8184
9114
  };
8185
- var formatBytes3 = (bytes) => {
9115
+ var formatBytes4 = (bytes) => {
8186
9116
  if (bytes < 1024) return `${bytes} B`;
8187
9117
  const units = ["KiB", "MiB", "GiB", "TiB"];
8188
9118
  let value = bytes / 1024;
@@ -8225,11 +9155,11 @@ var pruneUpgradeBackups = (runtime, store, backup, keep) => {
8225
9155
  const { removed, kept, held, failed, unsynced } = pruned.value;
8226
9156
  for (const entry of removed)
8227
9157
  runtime.writeLine(
8228
- `Removed upgrade backup ${entry.directory} \xB7 ${formatBytes3(entry.bytes)}${entry.interrupted ? " \xB7 finishing a removal a crash cut short" : entry.legacy ? " \xB7 from before 0.36, no recorded outcome" : ""}`
9158
+ `Removed upgrade backup ${entry.directory} \xB7 ${formatBytes4(entry.bytes)}${entry.interrupted ? " \xB7 finishing a removal a crash cut short" : entry.legacy ? " \xB7 from before 0.36, no recorded outcome" : ""}`
8229
9159
  );
8230
9160
  const freed = removed.reduce((total, entry) => total + entry.bytes, 0);
8231
9161
  runtime.writeLine(
8232
- `Upgrade backups \xB7 removed ${removed.length}${removed.length > 0 ? ` \xB7 ${formatBytes3(freed)} freed` : ""} \xB7 kept ${kept.length} (--keep-backups ${keep})`
9162
+ `Upgrade backups \xB7 removed ${removed.length}${removed.length > 0 ? ` \xB7 ${formatBytes4(freed)} freed` : ""} \xB7 kept ${kept.length} (--keep-backups ${keep})`
8233
9163
  );
8234
9164
  for (const entry of failed)
8235
9165
  runtime.writeLine(`Could not remove upgrade backup ${entry.directory}: ${entry.message}`);
@@ -8398,14 +9328,23 @@ var upgradeServer = async (args, runtime, store, existing) => {
8398
9328
  ...carried,
8399
9329
  assetContract: ASSET_CONTRACT,
8400
9330
  serverVersion: version,
8401
- ...bucket === void 0 ? {} : { bucket }
9331
+ ...bucket === void 0 ? {} : { bucket },
9332
+ // An install from before the mirrors gains both here; one that turned a mirror off keeps it off.
9333
+ mirrors: carried.mirrors ?? DEFAULT_MIRRORS
9334
+ };
9335
+ const previousToken = parseSecrets(previous.files.env).dockerHubToken;
9336
+ const secrets = {
9337
+ ...parseSecrets(previous.files.identity),
9338
+ ...previousToken === void 0 ? {} : { dockerHubToken: previousToken }
8402
9339
  };
8403
- const secrets = parseSecrets(previous.files.identity);
8404
9340
  const files = generationFiles(config, secrets, assets, previous.files.identity);
8405
9341
  const parsed = parseServerConfig(files.config);
8406
9342
  if (renderSecrets(parseSecrets(files.env), parsed) !== files.env)
8407
9343
  throw setupError("Invalid upgrade configuration.");
8408
- const edgeImage = files.edge === void 0 ? {} : { edgeImage: edgeImageOf(files.edge) };
9344
+ const edgeImage = {
9345
+ ...files.edge === void 0 ? {} : { edgeImage: edgeImageOf(files.edge) },
9346
+ ...files.mirrors === void 0 ? {} : { mirrorImages: mirrorImagesOf(files.mirrors) }
9347
+ };
8409
9348
  await checkLocalImages(
8410
9349
  runtime,
8411
9350
  { config, ...edgeImage },
@@ -8490,6 +9429,7 @@ var upgradeServer = async (args, runtime, store, existing) => {
8490
9429
  );
8491
9430
  }
8492
9431
  runtime.writeLine(`Upgraded to ${version}. Retained database backup: ${backup.directory}`);
9432
+ for (const line of mirrorsChangedLines(existing.config.mirrors, config)) runtime.writeLine(line);
8493
9433
  pruneUpgradeBackups(runtime, store, backup, options.keepBackups);
8494
9434
  };
8495
9435
  var observeEdgeCertificate = async (runtime, installation, host) => {
@@ -8593,6 +9533,75 @@ var exposureReportOf = (value) => {
8593
9533
  const parsed = items.flatMap(exposureItemOf);
8594
9534
  return parsed.length === items.length && parsed.length > 0 ? { declared, items: parsed } : null;
8595
9535
  };
9536
+ var mirrorStatusLines = async (runtime, installation, runningServices) => {
9537
+ const mirrors = installation.config.mirrors;
9538
+ if (mirrors === void 0) return [];
9539
+ const run2 = (args) => runtime.run("docker", serverComposeArgs(composeTarget(installation), args), {
9540
+ timeoutMs: serverProcessDeadlines.ordinary
9541
+ });
9542
+ const failed = (output) => output.status !== 0 || output.error !== void 0 ? outputDetail(output) : null;
9543
+ const diskOf = async (service, directory) => {
9544
+ const probe = await run2(["exec", "-T", service, "sh", "-c", mirrorDiskProbe(directory)]);
9545
+ return failed(probe) ?? mirrorDiskOf(probe.stdout) ?? "du and df answered nothing readable";
9546
+ };
9547
+ const lines = [];
9548
+ const npmRunning = runningServices.includes("npm-mirror");
9549
+ if (mirrors.npm === null || !npmRunning) {
9550
+ lines.push(observedNpmMirrorLine(mirrors, { running: false, disk: "", traffic: "" }));
9551
+ } else {
9552
+ const log = await run2([
9553
+ "logs",
9554
+ "--no-color",
9555
+ "--no-log-prefix",
9556
+ "--since",
9557
+ "24h",
9558
+ "npm-mirror"
9559
+ ]);
9560
+ lines.push(
9561
+ observedNpmMirrorLine(mirrors, {
9562
+ running: true,
9563
+ disk: await diskOf("npm-mirror", "/var/cache/npm-mirror"),
9564
+ traffic: failed(log) ?? npmMirrorTraffic(log.stdout)
9565
+ })
9566
+ );
9567
+ }
9568
+ const dockerRunning = runningServices.includes("docker-mirror");
9569
+ if (mirrors.docker === null || !dockerRunning) {
9570
+ lines.push(
9571
+ observedDockerMirrorLine(mirrors, { running: false, disk: "", traffic: "", startedAt: null })
9572
+ );
9573
+ } else {
9574
+ const metrics = await run2([
9575
+ "exec",
9576
+ "-T",
9577
+ "docker-mirror",
9578
+ "wget",
9579
+ "-q",
9580
+ "-O",
9581
+ "-",
9582
+ "http://127.0.0.1:5001/metrics"
9583
+ ]);
9584
+ const started = await runtime.run("docker", [
9585
+ "--context",
9586
+ installation.config.dockerContext,
9587
+ "container",
9588
+ "inspect",
9589
+ "--format",
9590
+ "{{.State.StartedAt}}",
9591
+ DOCKER_MIRROR_CONTAINER
9592
+ ]);
9593
+ const startedAt = failed(started) === null ? started.stdout.trim().replace(/\.\d+Z$/, "Z") : "";
9594
+ lines.push(
9595
+ observedDockerMirrorLine(mirrors, {
9596
+ running: true,
9597
+ disk: await diskOf("docker-mirror", "/var/lib/registry"),
9598
+ traffic: failed(metrics) ?? dockerMirrorTraffic(metrics.stdout) ?? "the registry reported no proxy counters",
9599
+ startedAt: startedAt === "" ? null : startedAt
9600
+ })
9601
+ );
9602
+ }
9603
+ return lines;
9604
+ };
8596
9605
  var serverStatus = async (runtime, installation) => {
8597
9606
  const { config } = installation;
8598
9607
  runtime.writeLine(`Pinned Mend ${config.serverVersion} at ${config.appUrl}`);
@@ -8616,6 +9625,8 @@ var serverStatus = async (runtime, installation) => {
8616
9625
  })
8617
9626
  );
8618
9627
  }
9628
+ for (const line of await mirrorStatusLines(runtime, installation, runningServices))
9629
+ runtime.writeLine(line);
8619
9630
  if (!runningServices.includes("mend")) {
8620
9631
  runtime.writeLine("Mend is stopped. No health claim was made.");
8621
9632
  return;
@@ -8684,7 +9695,10 @@ var manageServer = async (command, args, runtime, store) => {
8684
9695
  "Mend",
8685
9696
  "Postgres",
8686
9697
  ...installation.config.bucket === "garage" ? ["Garage"] : [],
8687
- ...installation.config.edgeHost === void 0 ? [] : ["the edge"]
9698
+ ...installation.config.edgeHost === void 0 ? [] : ["the edge"],
9699
+ ...mirrorServices(installation.config.mirrors).map(
9700
+ (service) => service === "npm-mirror" ? "the npm mirror" : "the Docker mirror"
9701
+ )
8688
9702
  ];
8689
9703
  runtime.writeLine(
8690
9704
  `${stopped.slice(0, -1).join(", ")} and ${stopped.at(-1)} stopped. Volumes, configuration and workspace containers are retained.`
@@ -8728,7 +9742,13 @@ var nodeServerRuntime = () => {
8728
9742
  `),
8729
9743
  dockerDaemonFacts: hostDockerDaemonFacts,
8730
9744
  readLogin: (configDir) => savedLogin(configDir, environment),
8731
- probeSsh: probeSshFromHere
9745
+ probeSsh: probeSshFromHere,
9746
+ readStdin: async () => {
9747
+ const chunks = [];
9748
+ for await (const chunk of process.stdin)
9749
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : Buffer.from(chunk));
9750
+ return Buffer.concat(chunks).toString("utf8");
9751
+ }
8732
9752
  };
8733
9753
  };
8734
9754
  var sshBannerAt = async (host, port, timeoutMs) => {
@@ -9311,6 +10331,7 @@ var scanSkillLibrary = (root) => {
9311
10331
  };
9312
10332
 
9313
10333
  // src/ssh-setup.ts
10334
+ import * as fs19 from "node:fs";
9314
10335
  import * as os12 from "node:os";
9315
10336
  import * as path19 from "node:path";
9316
10337
 
@@ -9837,6 +10858,164 @@ var setup = async (api2, cliHome, serverUrl, args) => {
9837
10858
  `connect with ssh ${view.gateway.usernamePrefix}-<workspace-id>@${parsedTarget.value.alias} ${dim5("\xB7 the VS Code extension uses this automatically")}`
9838
10859
  );
9839
10860
  };
10861
+ var errorText = (cause) => cause instanceof Error ? cause.message : String(cause);
10862
+ var thisMachineKeys = (view, cliHome, serverUrl) => {
10863
+ const fingerprints = /* @__PURE__ */ new Set();
10864
+ const unreadable = [];
10865
+ const target = parseWorkspaceSshTarget({
10866
+ serverUrl,
10867
+ publishedPort: view?.gateway?.port ?? 22
10868
+ });
10869
+ const config = readWorkspaceSshConfig(sshConfigPath());
10870
+ const configured = target.ok && config.ok ? configuredWorkspaceSshIdentityFile(config.value, target.value) : null;
10871
+ const publicPaths = /* @__PURE__ */ new Set();
10872
+ if (configured !== null) {
10873
+ publicPaths.add(configured.endsWith(".pub") ? configured : `${configured}.pub`);
10874
+ }
10875
+ const keyDirectory = path19.join(cliHome, "ssh");
10876
+ try {
10877
+ for (const entry of fs19.readdirSync(keyDirectory)) {
10878
+ if (entry.endsWith(".pub")) publicPaths.add(path19.join(keyDirectory, entry));
10879
+ }
10880
+ } catch (cause) {
10881
+ const code = cause instanceof Error && "code" in cause ? cause.code : void 0;
10882
+ if (code !== "ENOENT") unreadable.push(`${keyDirectory}: ${errorText(cause)}`);
10883
+ }
10884
+ let missingPublicHalf = null;
10885
+ for (const publicPath of publicPaths) {
10886
+ let line;
10887
+ try {
10888
+ line = fs19.readFileSync(publicPath, "utf8");
10889
+ } catch (cause) {
10890
+ const code = cause instanceof Error && "code" in cause ? cause.code : void 0;
10891
+ if (code === "ENOENT") missingPublicHalf = publicPath;
10892
+ else unreadable.push(`${publicPath}: ${errorText(cause)}`);
10893
+ continue;
10894
+ }
10895
+ const fingerprint = workspaceSshPublicKeyFingerprint(line);
10896
+ if (fingerprint.ok) fingerprints.add(fingerprint.value);
10897
+ else unreadable.push(`${publicPath}: ${fingerprint.error.message}`);
10898
+ }
10899
+ if (missingPublicHalf !== null) {
10900
+ const picked = pickWorkspaceSshKey({
10901
+ configHome: cliHome,
10902
+ configuredIdentityFile: configured,
10903
+ create: false
10904
+ });
10905
+ if (picked.ok && picked.value !== null) fingerprints.add(picked.value.fingerprint);
10906
+ else {
10907
+ unreadable.push(
10908
+ `${missingPublicHalf}: missing${picked.ok ? "" : `, and ${picked.error.message}`}`
10909
+ );
10910
+ }
10911
+ }
10912
+ return { fingerprints, unreadable };
10913
+ };
10914
+ var findKey = (keys, wanted) => {
10915
+ const fingerprint = wanted.startsWith("SHA256:") ? wanted : `SHA256:${wanted}`;
10916
+ return keys.find((key) => key.fingerprint === fingerprint || key.sshKeyId === wanted);
10917
+ };
10918
+ var REMOVED_KEY_EFFECT = "the gateway refuses it from the next connection; a connection already open stays open until it ends";
10919
+ var listKeys = async (api2, cliHome, serverUrl, args) => {
10920
+ const view = await api2("GET", "/workspace-ssh");
10921
+ const local = thisMachineKeys(view, cliHome, serverUrl).fingerprints;
10922
+ if (args.includes("--json")) {
10923
+ console.log(
10924
+ JSON.stringify(
10925
+ view.keys.map((key) => ({ ...key, thisMachine: local.has(key.fingerprint) })),
10926
+ null,
10927
+ 2
10928
+ )
10929
+ );
10930
+ return;
10931
+ }
10932
+ if (view.keys.length === 0) {
10933
+ say5(`no workspace ssh keys registered ${dim5("\xB7 run: mend ssh setup")}`);
10934
+ return;
10935
+ }
10936
+ const width = Math.max(...view.keys.map((key) => key.name.length));
10937
+ for (const key of view.keys) {
10938
+ say5(
10939
+ `${key.fingerprint} ${key.name.padEnd(width)} ${dim5(`${key.algorithm} \xB7 registered ${key.createdAt.slice(0, 10)}`)}${local.has(key.fingerprint) ? ` ${green5("\u25CF this machine")}` : ""}`
10940
+ );
10941
+ }
10942
+ };
10943
+ var removeKey = async (api2, cliHome, serverUrl, args) => {
10944
+ const [wanted] = args;
10945
+ if (wanted === void 0 || wanted.startsWith("-")) {
10946
+ return showFailure("usage: mend ssh keys remove <fingerprint> \xB7 mend ssh keys lists them");
10947
+ }
10948
+ const view = await api2("GET", "/workspace-ssh");
10949
+ const key = findKey(view.keys, wanted);
10950
+ if (key === void 0) {
10951
+ return showFailure(
10952
+ `none of your registered keys has fingerprint ${wanted} \xB7 mend ssh keys lists them`
10953
+ );
10954
+ }
10955
+ const removed = await api2(
10956
+ "DELETE",
10957
+ `/workspace-ssh/keys/${encodeURIComponent(key.sshKeyId)}`
10958
+ );
10959
+ say5(`removed ${removed.fingerprint} ${dim5(`\xB7 ${removed.name}`)}`);
10960
+ say5(dim5(REMOVED_KEY_EFFECT));
10961
+ if (thisMachineKeys(view, cliHome, serverUrl).fingerprints.has(removed.fingerprint)) {
10962
+ say5(
10963
+ dim5(
10964
+ "this machine's key: the file and the ~/.ssh/config block stay; mend ssh setup registers it again"
10965
+ )
10966
+ );
10967
+ }
10968
+ };
10969
+ var removeThisMachineKey = async (api2, cliHome, serverUrl) => {
10970
+ let view;
10971
+ try {
10972
+ view = await api2("GET", "/workspace-ssh");
10973
+ } catch (cause) {
10974
+ const local2 = thisMachineKeys(null, cliHome, serverUrl);
10975
+ return {
10976
+ removed: [],
10977
+ stillActive: [...local2.fingerprints],
10978
+ problem: `the server's key list could not be read: ${errorText(cause)}`
10979
+ };
10980
+ }
10981
+ const local = thisMachineKeys(view, cliHome, serverUrl);
10982
+ const removed = [];
10983
+ const stillActive = [];
10984
+ const problems = [];
10985
+ for (const key of view.keys.filter(
10986
+ (candidate) => local.fingerprints.has(candidate.fingerprint)
10987
+ )) {
10988
+ try {
10989
+ await api2("DELETE", `/workspace-ssh/keys/${encodeURIComponent(key.sshKeyId)}`);
10990
+ removed.push(key.fingerprint);
10991
+ } catch (cause) {
10992
+ stillActive.push(key.fingerprint);
10993
+ problems.push(`${key.fingerprint} was not removed: ${errorText(cause)}`);
10994
+ }
10995
+ }
10996
+ if (local.unreadable.length > 0) {
10997
+ problems.push(
10998
+ `this machine's key could not be read (${local.unreadable.join("; ")}), so Mend cannot tell which registered key is this machine's`
10999
+ );
11000
+ }
11001
+ return { removed, stillActive, problem: problems.length === 0 ? null : problems.join("; ") };
11002
+ };
11003
+ var keysCommand = async (api2, cliHome, serverUrl, args) => {
11004
+ const [subcommand, ...rest] = args;
11005
+ switch (subcommand) {
11006
+ case void 0:
11007
+ case "list":
11008
+ case "--json":
11009
+ return listKeys(api2, cliHome, serverUrl, args);
11010
+ case "remove":
11011
+ case "rm":
11012
+ return removeKey(api2, cliHome, serverUrl, rest);
11013
+ default:
11014
+ showFailure(
11015
+ `Unknown ssh keys subcommand "${subcommand}". Try: mend ssh keys [--json] \xB7 mend ssh keys remove <fingerprint>`
11016
+ );
11017
+ }
11018
+ };
9840
11019
  var sshCommand = async (args, api2, cliHome, serverUrl) => {
9841
11020
  const [subcommand, ...rest] = args;
9842
11021
  switch (subcommand) {
@@ -9845,15 +11024,17 @@ var sshCommand = async (args, api2, cliHome, serverUrl) => {
9845
11024
  return showStatus(api2, cliHome, serverUrl, rest);
9846
11025
  case "setup":
9847
11026
  return setup(api2, cliHome, serverUrl, rest);
11027
+ case "keys":
11028
+ return keysCommand(api2, cliHome, serverUrl, rest);
9848
11029
  default:
9849
11030
  showFailure(
9850
- `Unknown ssh subcommand "${subcommand}". Try: mend ssh \xB7 mend ssh setup [--key <path>] [--host <hostname>]`
11031
+ `Unknown ssh subcommand "${subcommand}". Try: mend ssh \xB7 mend ssh setup [--key <path>] [--host <hostname>] \xB7 mend ssh keys [remove <fingerprint>]`
9851
11032
  );
9852
11033
  }
9853
11034
  };
9854
11035
 
9855
11036
  // src/uninstall.ts
9856
- import * as fs19 from "node:fs";
11037
+ import * as fs20 from "node:fs";
9857
11038
  import * as path20 from "node:path";
9858
11039
  var UNINSTALL_USAGE = "usage: mend uninstall [--all | --server | --home] [--yes]";
9859
11040
  var parseUninstallArgs = (args) => {
@@ -9876,14 +11057,14 @@ var parseUninstallArgs = (args) => {
9876
11057
  var SERVER_FILES = ["identity.env", "active", "generations", "backups"];
9877
11058
  var countEntries = (directory) => {
9878
11059
  try {
9879
- return fs19.readdirSync(directory).length;
11060
+ return fs20.readdirSync(directory).length;
9880
11061
  } catch {
9881
11062
  return 0;
9882
11063
  }
9883
11064
  };
9884
11065
  var exists = (file) => {
9885
11066
  try {
9886
- fs19.lstatSync(file);
11067
+ fs20.lstatSync(file);
9887
11068
  return true;
9888
11069
  } catch {
9889
11070
  return false;
@@ -9892,7 +11073,7 @@ var exists = (file) => {
9892
11073
  var managedBlockCount = (sshConfigFile) => {
9893
11074
  let config;
9894
11075
  try {
9895
- config = fs19.readFileSync(sshConfigFile, "utf8");
11076
+ config = fs20.readFileSync(sshConfigFile, "utf8");
9896
11077
  } catch {
9897
11078
  return 0;
9898
11079
  }
@@ -9930,6 +11111,7 @@ var describeUninstall = async (runtime, scope) => {
9930
11111
  appUrl: read.value.config.appUrl,
9931
11112
  dockerContext: read.value.config.dockerContext,
9932
11113
  edgeHost: read.value.config.edgeHost ?? null,
11114
+ mirrors: mirrorServices(read.value.config.mirrors),
9933
11115
  generations: countEntries(path20.join(configDir, "generations")),
9934
11116
  backups: countEntries(path20.join(configDir, "backups"))
9935
11117
  };
@@ -9959,8 +11141,12 @@ var planLines = (plan, configDir) => {
9959
11141
  `server Mend ${version}${appUrl === "" ? "" : ` at ${appUrl}`}${dockerContext === "" ? "" : ` \xB7 docker context ${dockerContext}`}`
9960
11142
  );
9961
11143
  if (dockerContext !== "") {
9962
- const edge = edgeHost === null ? [] : ["edge"];
9963
- const edgeVolumes = edgeHost === null ? [] : ["mend-edge-data", "mend-edge-config"];
11144
+ const mirrors = plan.server.mirrors ?? [];
11145
+ const edge = [...edgeHost === null ? [] : ["edge"], ...mirrors];
11146
+ const edgeVolumes = [
11147
+ ...edgeHost === null ? [] : ["mend-edge-data", "mend-edge-config"],
11148
+ ...mirrors.map((service) => `mend-${service}`)
11149
+ ];
9964
11150
  lines.push(
9965
11151
  ` containers ${["mend", "postgres", "garage", ...edge].join(", ")} \xB7 volumes ${[MEND_DOCKER_NAMESPACE_WITH_GARAGE.store, ...secondaryVolumesOf(MEND_DOCKER_NAMESPACE_WITH_GARAGE), "mend-config", "mend-ssh", "mend-postgres", ...edgeVolumes].join(", ")} \xB7 image ghcr.io/sealant-sh/mend:${version}${edgeHost === null ? "" : ` \xB7 the edge for ${edgeHost}`}`
9966
11152
  );
@@ -9980,6 +11166,9 @@ var planLines = (plan, configDir) => {
9980
11166
  if (plan.home.managedSshBlocks > 0) {
9981
11167
  parts.push(`${plural(plan.home.managedSshBlocks, "managed block")} in ~/.ssh/config`);
9982
11168
  }
11169
+ if (plan.home.signedIn !== null) {
11170
+ parts.push(`this machine's workspace ssh key on ${plan.home.signedIn.url}, if registered`);
11171
+ }
9983
11172
  lines.push(parts.length === 0 ? "home nothing of Mend's here" : `home ${parts[0]}`);
9984
11173
  for (const part of parts.slice(1)) lines.push(` ${part}`);
9985
11174
  }
@@ -10019,7 +11208,9 @@ var removeServer = async (runtime, failures, leftovers) => {
10019
11208
  `docker compose down failed: ${(down.error ?? down.stderr.trim()) || "no output"}. Containers and files are retained; fix Docker and run mend uninstall again.`
10020
11209
  );
10021
11210
  }
10022
- server.writeLine("removed containers mend, postgres, garage and the Compose-owned volumes");
11211
+ server.writeLine(
11212
+ `removed containers ${["mend", "postgres", "garage", ...mirrorServices(installation.config.mirrors)].join(", ")} and the Compose-owned volumes`
11213
+ );
10023
11214
  const namespace = installation.config.bucket === "garage" ? MEND_DOCKER_NAMESPACE_WITH_GARAGE : MEND_DOCKER_NAMESPACE;
10024
11215
  const dataVolumes = [namespace.store, ...secondaryVolumesOf(namespace)];
10025
11216
  const identity = store.readIdentity();
@@ -10073,7 +11264,7 @@ var removeServer = async (runtime, failures, leftovers) => {
10073
11264
  }
10074
11265
  }
10075
11266
  for (const name of SERVER_FILES) {
10076
- fs19.rmSync(path20.join(configDir, name), { recursive: true, force: true });
11267
+ fs20.rmSync(path20.join(configDir, name), { recursive: true, force: true });
10077
11268
  }
10078
11269
  server.writeLine(`removed ${configDir}/{identity.env, active, generations, backups}`);
10079
11270
  },
@@ -10084,20 +11275,20 @@ var removeServer = async (runtime, failures, leftovers) => {
10084
11275
  var removeHome = async (runtime, plan, failures, leftovers) => {
10085
11276
  const { server } = runtime;
10086
11277
  if (plan.cliConfig !== null) {
10087
- fs19.rmSync(plan.cliConfig, { force: true });
11278
+ fs20.rmSync(plan.cliConfig, { force: true });
10088
11279
  server.writeLine(`removed ${plan.cliConfig}`);
10089
11280
  }
10090
11281
  if (plan.sshDirectory !== null) {
10091
- fs19.rmSync(plan.sshDirectory, { recursive: true, force: true });
11282
+ fs20.rmSync(plan.sshDirectory, { recursive: true, force: true });
10092
11283
  server.writeLine(`removed ${plan.sshDirectory}`);
10093
11284
  }
10094
11285
  if (plan.managedSshBlocks > 0) {
10095
11286
  try {
10096
- const before = fs19.readFileSync(runtime.sshConfigFile, "utf8");
11287
+ const before = fs20.readFileSync(runtime.sshConfigFile, "utf8");
10097
11288
  const stripped = stripManagedWorkspaceSshBlocks(before);
10098
11289
  if (!stripped.ok) throw stripped.error;
10099
11290
  if (stripped.value.removed > 0) {
10100
- fs19.writeFileSync(runtime.sshConfigFile, stripped.value.config, { mode: 384 });
11291
+ fs20.writeFileSync(runtime.sshConfigFile, stripped.value.config, { mode: 384 });
10101
11292
  server.writeLine(
10102
11293
  `removed ${plural(stripped.value.removed, "managed block")} from ${runtime.sshConfigFile}`
10103
11294
  );
@@ -10110,12 +11301,12 @@ var removeHome = async (runtime, plan, failures, leftovers) => {
10110
11301
  }
10111
11302
  let remaining = [];
10112
11303
  try {
10113
- remaining = fs19.readdirSync(runtime.cliHome);
11304
+ remaining = fs20.readdirSync(runtime.cliHome);
10114
11305
  } catch {
10115
11306
  return;
10116
11307
  }
10117
11308
  if (remaining.length === 0) {
10118
- fs19.rmdirSync(runtime.cliHome);
11309
+ fs20.rmdirSync(runtime.cliHome);
10119
11310
  server.writeLine(`removed ${runtime.cliHome}`);
10120
11311
  } else {
10121
11312
  leftovers.push(
@@ -10126,6 +11317,21 @@ var removeHome = async (runtime, plan, failures, leftovers) => {
10126
11317
  var executeUninstall = async (runtime, plan) => {
10127
11318
  const failures = [];
10128
11319
  const leftovers = [];
11320
+ if (plan.home !== null && plan.home.signedIn !== null) {
11321
+ const { url } = plan.home.signedIn;
11322
+ const removal = await runtime.removeWorkspaceSshKey();
11323
+ for (const fingerprint of removal.removed) {
11324
+ runtime.server.writeLine(
11325
+ `removed workspace ssh key ${fingerprint} on ${url} \xB7 the gateway refuses it from the next connection`
11326
+ );
11327
+ }
11328
+ if (removal.problem !== null) {
11329
+ const [first] = removal.stillActive;
11330
+ failures.push(
11331
+ first === void 0 ? `this machine's workspace ssh key on ${url} may still be registered: ${removal.problem}. From another signed-in machine, mend ssh keys lists your keys and mend ssh keys remove <fingerprint> removes one, or use Settings \u2192 Workspace SSH` : `workspace ssh key ${removal.stillActive.join(", ")} is still registered on ${url}: ${removal.problem}. From another signed-in machine: mend ssh keys remove ${first}, or use Settings \u2192 Workspace SSH`
11332
+ );
11333
+ }
11334
+ }
10129
11335
  if (plan.home?.signedIn !== null && plan.home?.signedIn?.deviceId != null) {
10130
11336
  const failure2 = await runtime.revokeDevice();
10131
11337
  if (failure2 === null) {
@@ -10324,14 +11530,14 @@ var mendCliHome = () => {
10324
11530
  "mend"
10325
11531
  );
10326
11532
  const legacy = path21.join(os13.homedir(), ".mend");
10327
- return !fs20.existsSync(preferred) && fs20.existsSync(legacy) ? legacy : preferred;
11533
+ return !fs21.existsSync(preferred) && fs21.existsSync(legacy) ? legacy : preferred;
10328
11534
  };
10329
11535
  var CONFIG_PATH = path21.join(mendCliHome(), "cli.json");
10330
11536
  var loadConfig = () => {
10331
11537
  let fileConfig = {};
10332
- if (fs20.existsSync(CONFIG_PATH)) {
11538
+ if (fs21.existsSync(CONFIG_PATH)) {
10333
11539
  try {
10334
- fileConfig = JSON.parse(fs20.readFileSync(CONFIG_PATH, "utf8"));
11540
+ fileConfig = JSON.parse(fs21.readFileSync(CONFIG_PATH, "utf8"));
10335
11541
  } catch {
10336
11542
  fail7(`could not parse ${CONFIG_PATH}`);
10337
11543
  }
@@ -11468,8 +12674,9 @@ var printService = (config, service) => {
11468
12674
  const status = service.status === "reachable" ? green7(service.status) : amber2(service.status);
11469
12675
  const port = `:${service.workspacePort ?? "?"}${service.protocol === "udp" ? "/udp" : ""}`;
11470
12676
  const statusPad = " ".repeat(Math.max(1, 12 - service.status.length));
12677
+ const processLine2 = service.processId === null ? "no process" : `process ${service.processId.slice(0, 8)}`;
11471
12678
  say7(
11472
- `${(service.label ?? service.id.slice(0, 8)).padEnd(14)} ${status}${statusPad}${dim7(port.padEnd(7))} ${dim7(service.id.slice(0, 8))}`
12679
+ `${(service.label ?? service.id.slice(0, 8)).padEnd(14)} ${status}${statusPad}${dim7(port.padEnd(7))} ${dim7(`service ${service.id.slice(0, 8)} \xB7 ${processLine2}`)}`
11473
12680
  );
11474
12681
  printServiceAccess(config, service);
11475
12682
  printWorkspaceTtlFailure(service);
@@ -11512,8 +12719,30 @@ var serviceAdd = async (config, args) => {
11512
12719
  say7(dim7(` nothing answered on :${port} yet \u2014 the URL goes live when something listens`));
11513
12720
  }
11514
12721
  };
11515
- var serviceList = async (config) => {
12722
+ var serviceList = async (config, args) => {
12723
+ const unknown = args.find((arg) => arg !== "--json");
12724
+ if (unknown !== void 0) {
12725
+ return fail7(`unknown argument ${unknown} \xB7 ${usageOf("service list")}`);
12726
+ }
11516
12727
  const services = await fetchServices(config);
12728
+ if (args.includes("--json")) {
12729
+ printJson({
12730
+ version: 1,
12731
+ services: services.map((service) => ({
12732
+ id: service.id,
12733
+ name: service.label,
12734
+ sessionId: service.sessionId,
12735
+ processId: service.processId,
12736
+ status: service.status,
12737
+ workspacePort: service.workspacePort,
12738
+ protocol: service.protocol,
12739
+ hostPort: service.hostPort,
12740
+ authority: service.authority,
12741
+ browserUrl: service.browserUrl
12742
+ }))
12743
+ });
12744
+ return;
12745
+ }
11517
12746
  if (services.length === 0) {
11518
12747
  say7(dim7("no live services \u2014 mend service add <port> adopts a listening one"));
11519
12748
  return;
@@ -11552,48 +12781,184 @@ var autoConnect = async (config, service) => {
11552
12781
  say7(dim7(" remote server \u2014 tunneling the port here \xB7 Ctrl-C stops the tunnel, not the Service"));
11553
12782
  await tunnelServices(config, [service], null);
11554
12783
  };
11555
- var SERVICE_WAIT_MS = (() => {
12784
+ var SERVICE_WAIT_DEFAULT_MS = (() => {
11556
12785
  const configured = Number(process.env["MEND_SERVICE_WAIT_MS"]);
11557
- return Number.isFinite(configured) && configured > 0 ? configured : 9e4;
12786
+ return Number.isFinite(configured) && configured > 0 ? configured : 10 * 6e4;
11558
12787
  })();
11559
- var withinServiceWait = async (work, deadline) => {
11560
- const bounded = await beforeDeadline(work, clock, deadline);
12788
+ var SERVICE_RUN_VALUED = /* @__PURE__ */ new Set(["--port", "--name", "--timeout"]);
12789
+ var serviceWaitOf = (head) => {
12790
+ const timeoutFlag = head.indexOf("--timeout");
12791
+ const timeoutText = timeoutFlag === -1 ? void 0 : head[timeoutFlag + 1];
12792
+ if (!head.includes("--wait")) {
12793
+ return timeoutFlag === -1 ? null : fail7("--timeout bounds --wait: pass --wait too, or leave --timeout out");
12794
+ }
12795
+ const timeoutMs = timeoutFlag === -1 ? SERVICE_WAIT_DEFAULT_MS : timeoutText === void 0 ? null : parseDuration(timeoutText);
12796
+ if (timeoutMs === null) return fail7(`${DURATION_ISSUE} \xB7 ${usageOf("service run")}`);
12797
+ return { deadline: clock.now() + timeoutMs, timeoutMs };
12798
+ };
12799
+ var serviceRunPositionals = (head) => head.filter(
12800
+ (arg, index) => !arg.startsWith("--") && !SERVICE_RUN_VALUED.has(head[index - 1] ?? "")
12801
+ );
12802
+ var failWith = (code, message) => {
12803
+ process.stderr.write(`mend: ${redactCredentials(message)}
12804
+ `);
12805
+ return exitFlushed(code);
12806
+ };
12807
+ var withinServiceWait = async (work, wait) => {
12808
+ const bounded = await beforeDeadline(work, clock, wait?.deadline ?? null);
11561
12809
  if (bounded.done) return bounded.value;
11562
- process.stderr.write(
11563
- `mend: no answer within ${Math.round(SERVICE_WAIT_MS / 1e3)} s \xB7 the Service may still be starting \xB7 mend service list
11564
- `
12810
+ return failWith(
12811
+ WAIT_TIMED_OUT,
12812
+ `Mend gave no answer within ${durationLine(wait?.timeoutMs ?? 0)} \xB7 nothing was started`
12813
+ );
12814
+ };
12815
+ var errorText2 = (error) => error instanceof Error ? error.message : String(error);
12816
+ var exitCodeText = (exitCode) => exitCode === null ? "no exit code reported" : `code ${exitCode}`;
12817
+ var readServiceStart = async (config, sessionId) => {
12818
+ const [services, sessionStatus] = await Promise.all([
12819
+ request(config, "GET", "/services?all=1"),
12820
+ request(config, "GET", `/sessions/${sessionId}`).then(
12821
+ (detail) => detail.session.status,
12822
+ (error) => {
12823
+ if (error instanceof MendRequestError && error.kind === "http" && error.status === 404) {
12824
+ return null;
12825
+ }
12826
+ throw error;
12827
+ }
12828
+ )
12829
+ ]);
12830
+ return { services, sessionStatus };
12831
+ };
12832
+ var reportAnswered = (config, service) => {
12833
+ const name = service.label;
12834
+ say7(`${green7("\u2713")} Service ${name} \xB7 ${service.status}`);
12835
+ printServiceEndpoint(config, service, false);
12836
+ say7(dim7(` logs: mend service logs ${name}`));
12837
+ if (willAutoConnect(config, service, false)) say7(dim7(` connect: mend service connect ${name}`));
12838
+ };
12839
+ var startServiceWaited = async (config, session, target, wait, spinner, start) => {
12840
+ const { name, port } = target;
12841
+ const startId = crypto.randomUUID();
12842
+ const after = durationLine(wait.timeoutMs);
12843
+ const stillStarting = (processId, more = "") => failWith(
12844
+ WAIT_TIMED_OUT,
12845
+ `${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`
12846
+ );
12847
+ const ended = (processId, status, exitCode, more) => failWith(
12848
+ SERVICE_PROCESS_ENDED,
12849
+ `${name} \xB7 process ${processId.slice(0, 8)} ${status} \xB7 ${exitCodeText(exitCode)} \xB7 before :${port} answered \xB7 ${more}`
12850
+ );
12851
+ const posted = await withSpinner(
12852
+ spinner,
12853
+ beforeDeadline(
12854
+ start(startId).then(
12855
+ (view) => ({ ok: true, view }),
12856
+ (error) => ({ ok: false, error })
12857
+ ),
12858
+ clock,
12859
+ wait.deadline
12860
+ )
11565
12861
  );
11566
- return exitFlushed(WAIT_TIMED_OUT);
11567
- };
11568
- var failUnlessAnswered = (config, service, wait) => {
11569
- if (!wait) return;
11570
- const name = service.label ?? service.id.slice(0, 8);
11571
- if (service.status !== "reachable") {
11572
- fail7(
11573
- `nothing answered on :${service.workspacePort} \xB7 ${service.status} \xB7 the Service keeps running \xB7 mend service logs ${name}`
12862
+ if (!posted.done) return stillStarting(null, " \xB7 the start request has not returned");
12863
+ let attemptId = null;
12864
+ if (posted.value.ok) {
12865
+ const { view } = posted.value;
12866
+ attemptId = answeredAttemptId(view, startId);
12867
+ if (attemptId !== null) {
12868
+ const first = serviceStartStateOf(
12869
+ { services: [view], sessionStatus: session.status },
12870
+ { attemptId, sessionId: session.id, startId }
12871
+ );
12872
+ if (first.kind === "answered") return reportAnswered(config, flattenService(view));
12873
+ if (first.kind === "process-ended") {
12874
+ return ended(first.processId, first.status, first.exitCode, `mend logs --service ${name}`);
12875
+ }
12876
+ }
12877
+ say7(
12878
+ `${amber2("\xB7")} ${name} \xB7 ${attemptId === null ? "started" : `process ${attemptId.slice(0, 8)} runs`} \xB7 :${port} has not answered yet \xB7 waiting up to ${after}; Mend probes the port every 20 s`
11574
12879
  );
11575
- }
11576
- if (willAutoConnect(config, service, false)) {
11577
- say7(dim7(` connect: mend service connect ${name}`));
12880
+ } else {
12881
+ const { error } = posted.value;
12882
+ if (!mayStillBeWorking(error)) {
12883
+ const read = await readWithin(
12884
+ () => request(config, "GET", "/services?all=1"),
12885
+ clock,
12886
+ wait.deadline
12887
+ ).catch(() => null);
12888
+ const found = read?.done === true ? findStartAttempt(read.value, { attemptId: null, sessionId: session.id, startId }) : void 0;
12889
+ if (found !== void 0 && found.attempt.exitedAt !== null) {
12890
+ return ended(
12891
+ found.attempt.id,
12892
+ found.attempt.status,
12893
+ found.attempt.exitCode,
12894
+ errorText2(error)
12895
+ );
12896
+ }
12897
+ return failWith(1, errorText2(error));
12898
+ }
12899
+ say7(dim7(` the start got no answer (${errorText2(error)}) \xB7 looking for the attempt it began\u2026`));
12900
+ }
12901
+ const started = clock.now();
12902
+ let line = `${name} \xB7 starting \xB7 waiting for :${port} to answer\u2026`;
12903
+ let nextReport = started + 6e4;
12904
+ const outcome = await withSpinner(
12905
+ () => line,
12906
+ waitForServiceStart({
12907
+ read: () => readServiceStart(config, session.id),
12908
+ target: { attemptId, sessionId: session.id, startId },
12909
+ deadline: wait.deadline,
12910
+ ...clock,
12911
+ onStarting: (state) => {
12912
+ const running = state.processId === null ? "" : ` \xB7 process ${state.processId.slice(0, 8)} runs`;
12913
+ line = `${name} \xB7 starting${running} \xB7 :${port} has not answered`;
12914
+ if (chrome.isTTY !== true && clock.now() >= nextReport) {
12915
+ nextReport = clock.now() + 6e4;
12916
+ say7(dim7(` ${line} \xB7 ${durationLine(clock.now() - started)} so far`));
12917
+ }
12918
+ }
12919
+ })
12920
+ ).catch((error) => failFlushed(errorText2(error)));
12921
+ switch (outcome.kind) {
12922
+ case "answered":
12923
+ return reportAnswered(config, flattenService(outcome.view));
12924
+ case "process-ended":
12925
+ return ended(
12926
+ outcome.processId,
12927
+ outcome.status,
12928
+ outcome.exitCode,
12929
+ `mend logs --service ${name}`
12930
+ );
12931
+ case "workspace-ended":
12932
+ return failWith(
12933
+ SERVICE_WORKSPACE_ENDED,
12934
+ `${name} \xB7 session ${session.id.slice(0, 8)} no longer exists, and its workspace went with it \xB7 before :${port} answered`
12935
+ );
12936
+ case "timeout":
12937
+ return stillStarting(outcome.last?.kind === "starting" ? outcome.last.processId : null);
12938
+ case "no-attempt":
12939
+ return failWith(
12940
+ 1,
12941
+ `${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`
12942
+ );
11578
12943
  }
11579
12944
  };
11580
12945
  var serviceRun = async (config, args) => {
11581
12946
  const dashdash = args.indexOf("--");
11582
12947
  const usage2 = usageOf("service run");
11583
- const wait = (dashdash === -1 ? args : args.slice(0, dashdash)).includes("--wait");
11584
- const deadline = wait ? clock.now() + SERVICE_WAIT_MS : null;
12948
+ const head = dashdash === -1 ? args : args.slice(0, dashdash);
12949
+ const wait = serviceWaitOf(head);
11585
12950
  if (dashdash === -1) {
11586
- const positionals = args.filter((a) => !a.startsWith("--"));
12951
+ const positionals = serviceRunPositionals(head);
11587
12952
  const name2 = positionals.at(-1);
11588
12953
  if (name2 === void 0) return fail7(usage2);
11589
12954
  const prefix2 = positionals.length > 1 ? positionals[0] : void 0;
11590
12955
  const session2 = await withinServiceWait(
11591
12956
  resolveLiveSession(config, prefix2, "service run"),
11592
- deadline
12957
+ wait
11593
12958
  );
11594
12959
  const recipes = await withinServiceWait(
11595
12960
  api(config, "GET", `/sessions/${session2.id}/recipes`),
11596
- deadline
12961
+ wait
11597
12962
  );
11598
12963
  const recipe = recipes.find((entry) => entry.name === name2);
11599
12964
  if (recipe === void 0) {
@@ -11602,34 +12967,43 @@ var serviceRun = async (config, args) => {
11602
12967
  recipes.length === 0 ? `no mend.toml recipes in this worktree \u2014 declare [service.${name2}] first` : `no recipe named "${name2}" \u2014 declared: ${known}`
11603
12968
  );
11604
12969
  }
11605
- if (wait && recipe.protocol === "udp") {
11606
- return fail7(`--wait needs a TCP port: ${recipe.name} is UDP, which has no probe`);
11607
- }
11608
- if (wait && recipe.command === null) {
11609
- return fail7(
11610
- `--wait needs a command Mend starts: ${recipe.name} declares only a port, which Mend adopts with one probe \xB7 run it without --wait, or give the recipe a command`
12970
+ const startRecipe = (startId) => request(config, "POST", `/sessions/${session2.id}/services/recipe`, {
12971
+ name: recipe.name,
12972
+ startId
12973
+ });
12974
+ if (wait !== null) {
12975
+ if (recipe.protocol === "udp") {
12976
+ return fail7(`--wait needs a TCP port: ${recipe.name} is UDP, which has no probe`);
12977
+ }
12978
+ if (recipe.command === null) {
12979
+ return fail7(
12980
+ `--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`
12981
+ );
12982
+ }
12983
+ return startServiceWaited(
12984
+ config,
12985
+ session2,
12986
+ { name: recipe.name, port: recipe.port },
12987
+ wait,
12988
+ `starting ${recipe.name} \u2014 waiting for :${recipe.port} to answer\u2026`,
12989
+ startRecipe
11611
12990
  );
11612
12991
  }
11613
12992
  const service2 = await withSpinner(
11614
12993
  recipe.command === null ? `adopting ${recipe.name} on :${recipe.port}\u2026` : recipe.protocol === "udp" ? `starting ${recipe.name} (udp :${recipe.port})\u2026` : `starting ${recipe.name} \u2014 waiting for :${recipe.port} to answer\u2026`,
11615
- withinServiceWait(
11616
- mutateService(config, "POST", `/sessions/${session2.id}/services/recipe`, {
11617
- name: recipe.name
11618
- }),
11619
- deadline
11620
- )
12994
+ mutateService(config, "POST", `/sessions/${session2.id}/services/recipe`, {
12995
+ name: recipe.name
12996
+ })
11621
12997
  );
11622
- const tunneling2 = willAutoConnect(config, service2, args.includes("--no-connect") || wait);
12998
+ const tunneling2 = willAutoConnect(config, service2, args.includes("--no-connect"));
11623
12999
  say7(`${green7("\u2713")} Service ${service2.label ?? ""} \xB7 ${service2.status}`);
11624
13000
  printServiceEndpoint(config, service2, tunneling2);
11625
13001
  say7(dim7(` logs: mend service logs ${service2.label ?? service2.id.slice(0, 8)}`));
11626
- failUnlessAnswered(config, service2, wait);
11627
13002
  if (tunneling2) await autoConnect(config, service2);
11628
13003
  return;
11629
13004
  }
11630
13005
  const argv = args.slice(dashdash + 1);
11631
13006
  if (argv.length === 0) return fail7(usage2);
11632
- const head = args.slice(0, dashdash);
11633
13007
  const portFlag = head.indexOf("--port");
11634
13008
  const port = portFlag === -1 ? Number.NaN : Number(head[portFlag + 1]);
11635
13009
  if (!Number.isInteger(port) || port < 1 || port > 65535) return fail7(usage2);
@@ -11641,34 +13015,33 @@ var serviceRun = async (config, args) => {
11641
13015
  if (http && https) return fail7(usage2);
11642
13016
  const browserScheme = https ? "https" : http ? "http" : null;
11643
13017
  if (protocol === "udp" && browserScheme !== null) return fail7(usage2);
11644
- if (protocol === "udp" && wait) {
13018
+ if (protocol === "udp" && wait !== null) {
11645
13019
  return fail7("--wait needs a TCP port: UDP has no probe, so nothing could be waited for");
11646
13020
  }
11647
- const prefix = head.find(
11648
- (a, i) => !a.startsWith("--") && (portFlag === -1 || i !== portFlag + 1) && (nameFlag === -1 || i !== nameFlag + 1)
11649
- );
11650
- const session = await withinServiceWait(
11651
- resolveLiveSession(config, prefix, "service run"),
11652
- deadline
11653
- );
13021
+ const prefix = serviceRunPositionals(head)[0];
13022
+ const session = await withinServiceWait(resolveLiveSession(config, prefix, "service run"), wait);
13023
+ const body = { argv, port, name, protocol, browserScheme };
13024
+ if (wait !== null) {
13025
+ return startServiceWaited(
13026
+ config,
13027
+ session,
13028
+ { name: name ?? argv[0] ?? "service", port },
13029
+ wait,
13030
+ `starting ${name ?? argv[0]} \u2014 waiting for :${port} to answer\u2026`,
13031
+ (startId) => request(config, "POST", `/sessions/${session.id}/services/run`, {
13032
+ ...body,
13033
+ startId
13034
+ })
13035
+ );
13036
+ }
11654
13037
  const service = await withSpinner(
11655
13038
  protocol === "udp" ? `starting ${name ?? argv[0]} (udp :${port})\u2026` : `starting ${name ?? argv[0]} \u2014 waiting for :${port} to answer\u2026`,
11656
- withinServiceWait(
11657
- mutateService(config, "POST", `/sessions/${session.id}/services/run`, {
11658
- argv,
11659
- port,
11660
- name,
11661
- protocol,
11662
- browserScheme
11663
- }),
11664
- deadline
11665
- )
13039
+ mutateService(config, "POST", `/sessions/${session.id}/services/run`, body)
11666
13040
  );
11667
- const tunneling = willAutoConnect(config, service, head.includes("--no-connect") || wait);
13041
+ const tunneling = willAutoConnect(config, service, head.includes("--no-connect"));
11668
13042
  say7(`${green7("\u2713")} Service ${service.label ?? ""} \xB7 ${service.status}`);
11669
13043
  printServiceEndpoint(config, service, tunneling);
11670
13044
  say7(dim7(` logs: mend service logs ${service.label ?? service.id.slice(0, 8)}`));
11671
- failUnlessAnswered(config, service, wait);
11672
13045
  if (tunneling) await autoConnect(config, service);
11673
13046
  };
11674
13047
  var serviceLogs = async (config, args) => {
@@ -11740,12 +13113,12 @@ var serviceRestart = async (config, args) => {
11740
13113
  var serviceInit = async (args) => {
11741
13114
  const root = gitTopLevel(process.cwd()) ?? process.cwd();
11742
13115
  const target = path21.join(root, "mend.toml");
11743
- if (fs20.existsSync(target)) {
13116
+ if (fs21.existsSync(target)) {
11744
13117
  return fail7(`${target} already exists \u2014 edit it directly (init never merges)`);
11745
13118
  }
11746
- const rootFiles = fs20.readdirSync(root);
13119
+ const rootFiles = fs21.readdirSync(root);
11747
13120
  const proposals = [];
11748
- const readRoot = (name) => fs20.readFileSync(path21.join(root, name), "utf8");
13121
+ const readRoot = (name) => fs21.readFileSync(path21.join(root, name), "utf8");
11749
13122
  if (rootFiles.includes("package.json")) {
11750
13123
  proposals.push(...proposeFromPackageJson(readRoot("package.json"), rootFiles));
11751
13124
  }
@@ -11755,13 +13128,13 @@ var serviceInit = async (args) => {
11755
13128
  );
11756
13129
  for (const glob of globs) {
11757
13130
  const parent = glob.replace(/\/?\*+$/, "");
11758
- const dirs = glob.endsWith("*") ? fs20.existsSync(path21.join(root, parent)) ? fs20.readdirSync(path21.join(root, parent)).map((dir) => path21.join(parent, dir)) : [] : [glob];
13131
+ const dirs = glob.endsWith("*") ? fs21.existsSync(path21.join(root, parent)) ? fs21.readdirSync(path21.join(root, parent)).map((dir) => path21.join(parent, dir)) : [] : [glob];
11759
13132
  for (const dir of dirs) {
11760
13133
  const manifest = path21.join(root, dir, "package.json");
11761
- if (!fs20.existsSync(manifest)) continue;
13134
+ if (!fs21.existsSync(manifest)) continue;
11762
13135
  for (const proposal of proposeFromWorkspacePackage(
11763
13136
  path21.basename(dir),
11764
- fs20.readFileSync(manifest, "utf8"),
13137
+ fs21.readFileSync(manifest, "utf8"),
11765
13138
  rootFiles
11766
13139
  )) {
11767
13140
  if (!proposals.some((existing) => existing.name === proposal.name)) {
@@ -11805,7 +13178,7 @@ var serviceInit = async (args) => {
11805
13178
  return;
11806
13179
  }
11807
13180
  }
11808
- fs20.writeFileSync(target, toml);
13181
+ fs21.writeFileSync(target, toml);
11809
13182
  say7(`${green7("\u2713")} wrote ${target} \u2014 commit it, then: mend service run ${proposals[0]?.name}`);
11810
13183
  };
11811
13184
  var tunnelServices = async (config, services, portOverride) => {
@@ -11896,8 +13269,9 @@ var serviceCommand = async (config, args) => {
11896
13269
  case "connect":
11897
13270
  return serviceConnect(config, rest);
11898
13271
  case "list":
13272
+ return serviceList(config, rest);
11899
13273
  case void 0:
11900
- return serviceList(config);
13274
+ return serviceList(config, []);
11901
13275
  case "logs":
11902
13276
  return serviceLogs(config, rest);
11903
13277
  case "restart":
@@ -12034,7 +13408,7 @@ var secretsCommand = async (config, args) => {
12034
13408
  const from = takeFlagValue4(rest, "--from");
12035
13409
  let bytes;
12036
13410
  try {
12037
- bytes = fs20.readFileSync(from ?? 0);
13411
+ bytes = fs21.readFileSync(from ?? 0);
12038
13412
  } catch (error) {
12039
13413
  return fail7(
12040
13414
  from === null ? `nothing on stdin: pass --from <file> or pipe the content in` : `${from}: ${error instanceof Error ? error.message : String(error)}`
@@ -12063,7 +13437,7 @@ var secretsCommand = async (config, args) => {
12063
13437
  for (const line of secretFileLines(view.files)) say7(line);
12064
13438
  };
12065
13439
  var isProvider = (value) => value === "claude" || value === "codex" || value === "github";
12066
- var readIfExists = (file) => fs20.existsSync(file) ? fs20.readFileSync(file, "utf8") : null;
13440
+ var readIfExists = (file) => fs21.existsSync(file) ? fs21.readFileSync(file, "utf8") : null;
12067
13441
  var localCredential = (provider) => {
12068
13442
  const home = os13.homedir();
12069
13443
  switch (provider) {
@@ -12220,7 +13594,7 @@ var claudeGrant = async () => {
12220
13594
  say7(dim7(" your own Claude login still works \xB7 verified"));
12221
13595
  }
12222
13596
  const kept = claudeGrantDir(home);
12223
- if (fs20.existsSync(kept)) {
13597
+ if (fs21.existsSync(kept)) {
12224
13598
  forgetGrant(kept);
12225
13599
  say7(dim7(` removed the copy an older mend kept in ${kept} \xB7 the server refreshes the login`));
12226
13600
  }
@@ -12276,7 +13650,7 @@ var connectCommand = async (config, args) => {
12276
13650
  }
12277
13651
  let secret;
12278
13652
  if (flags.includes("--from-stdin")) {
12279
- secret = fs20.readFileSync(0, "utf8").trim();
13653
+ secret = fs21.readFileSync(0, "utf8").trim();
12280
13654
  if (secret === "") return fail7("nothing on stdin");
12281
13655
  } else if (provider === "codex" && !flags.includes("--use-my-login")) {
12282
13656
  const grant = codexGrant({
@@ -12331,14 +13705,14 @@ var takeFlagValue4 = (args, flag) => {
12331
13705
  return at !== -1 && args[at + 1] !== void 0 ? String(args[at + 1]) : null;
12332
13706
  };
12333
13707
  var saveCliConfig = (next) => {
12334
- fs20.mkdirSync(path21.dirname(CONFIG_PATH), { recursive: true, mode: 448 });
12335
- fs20.writeFileSync(
13708
+ fs21.mkdirSync(path21.dirname(CONFIG_PATH), { recursive: true, mode: 448 });
13709
+ fs21.writeFileSync(
12336
13710
  CONFIG_PATH,
12337
13711
  `${JSON.stringify({ url: next.url, token: next.token, deviceId: next.deviceId }, null, 2)}
12338
13712
  `,
12339
13713
  { mode: 384 }
12340
13714
  );
12341
- fs20.chmodSync(CONFIG_PATH, 384);
13715
+ fs21.chmodSync(CONFIG_PATH, 384);
12342
13716
  };
12343
13717
  var login = async (config, args) => {
12344
13718
  await loginCommand(args, {
@@ -12351,7 +13725,7 @@ var login = async (config, args) => {
12351
13725
  });
12352
13726
  };
12353
13727
  var logout = async (config) => {
12354
- if (!fs20.existsSync(CONFIG_PATH) && config.token === null) {
13728
+ if (!fs21.existsSync(CONFIG_PATH) && config.token === null) {
12355
13729
  say7(dim7("nothing saved \u2014 already signed out"));
12356
13730
  return;
12357
13731
  }
@@ -12405,7 +13779,7 @@ var uninstallCommand = async (config, args) => {
12405
13779
  server,
12406
13780
  cliHome: mendCliHome(),
12407
13781
  sshConfigFile: path21.join(os13.homedir(), ".ssh", "config"),
12408
- signedIn: config.token === null || !fs20.existsSync(CONFIG_PATH) ? null : { url: config.url, deviceId: config.deviceId },
13782
+ signedIn: config.token === null || !fs21.existsSync(CONFIG_PATH) ? null : { url: config.url, deviceId: config.deviceId },
12409
13783
  revokeDevice: async () => {
12410
13784
  if (config.deviceId === null) return "no device id saved";
12411
13785
  try {
@@ -12414,7 +13788,12 @@ var uninstallCommand = async (config, args) => {
12414
13788
  } catch (error) {
12415
13789
  return error instanceof Error ? error.message : String(error);
12416
13790
  }
12417
- }
13791
+ },
13792
+ removeWorkspaceSshKey: () => removeThisMachineKey(
13793
+ (method, route, body) => request(config, method, route, body),
13794
+ mendCliHome(),
13795
+ config.url
13796
+ )
12418
13797
  };
12419
13798
  const plan = await describeUninstall(runtime, scope);
12420
13799
  say7(dim7(`mend uninstall \xB7 ${scope === "all" ? "everything" : scope}`));
@@ -12546,7 +13925,7 @@ var keysMode = async (config, args) => {
12546
13925
  access.bridge.connected ? `${green7("\u25CF")} signer connected \xB7 ${access.bridge.clientName ?? "unknown machine"}` : dim7(" no signer connected \u2014 mend shares the agent whenever it runs (or: mend keys share)")
12547
13926
  );
12548
13927
  };
12549
- var keysCommand = async (config, args) => {
13928
+ var keysCommand2 = async (config, args) => {
12550
13929
  const [verb] = args;
12551
13930
  switch (verb) {
12552
13931
  case "init":
@@ -12649,7 +14028,7 @@ var envLoad = async (config, args) => {
12649
14028
  const file = path21.resolve(positional[0] ?? ".env");
12650
14029
  let contents;
12651
14030
  try {
12652
- contents = fs20.readFileSync(file, "utf8");
14031
+ contents = fs21.readFileSync(file, "utf8");
12653
14032
  } catch {
12654
14033
  return fail7(`cannot read ${file} \u2014 pass a path: mend env load path/to/.env`);
12655
14034
  }
@@ -13026,11 +14405,11 @@ var pause = (ms, signal) => {
13026
14405
  };
13027
14406
  var clock = { sleep: pause, now: Date.now };
13028
14407
  var printJson = (value) => {
13029
- process.stdout.write(`${redactCredentials(JSON.stringify(value, null, 2))}
14408
+ process.stdout.write(`${jsonWithoutCredentials(value)}
13030
14409
  `);
13031
14410
  };
13032
14411
  var outputOnTerminal = false;
13033
- var stdoutErrorsHandled = false;
14412
+ var recordedOutput = false;
13034
14413
  var outputStopped = false;
13035
14414
  var restoreTerminal = () => {
13036
14415
  if (!outputOnTerminal || process.stdout.isTTY !== true) return;
@@ -13038,13 +14417,17 @@ var restoreTerminal = () => {
13038
14417
  process.stdout.write(`${TERMINAL_MODES_RESET}\x1B[0m`);
13039
14418
  };
13040
14419
  process.once("exit", restoreTerminal);
14420
+ var onClosedPipe = (error) => {
14421
+ if (error.code !== "EPIPE") throw error;
14422
+ if (recordedOutput) return;
14423
+ process.exit(0);
14424
+ };
14425
+ process.stdout.on("error", onClosedPipe);
14426
+ process.stderr.on("error", onClosedPipe);
13041
14427
  var writeOutput = (bytes) => {
13042
14428
  if (outputStopped) return Promise.reject(new Error("stopped by a signal"));
13043
14429
  if (process.stdout.isTTY === true) outputOnTerminal = true;
13044
- if (!stdoutErrorsHandled) {
13045
- stdoutErrorsHandled = true;
13046
- process.stdout.on("error", () => void 0);
13047
- }
14430
+ recordedOutput = true;
13048
14431
  return new Promise((resolve8, reject) => {
13049
14432
  process.stdout.write(bytes, (error) => {
13050
14433
  if (error === null || error === void 0) resolve8();
@@ -13224,26 +14607,57 @@ var resolveAnySession = async (config, word) => {
13224
14607
  if ("error" in picked) return fail7(picked.error);
13225
14608
  return picked.session;
13226
14609
  };
14610
+ var serviceLogsTarget = async (config, needle, sessionWord) => {
14611
+ const session = sessionWord === null ? null : await resolveAnySession(config, sessionWord);
14612
+ const picked = pickServiceAttempt(
14613
+ await fetchServiceViews(config, true),
14614
+ needle,
14615
+ session?.id ?? null
14616
+ );
14617
+ if ("error" in picked) return fail7(picked.error);
14618
+ return { processId: picked.processId, again: `mend logs --service ${needle}` };
14619
+ };
13227
14620
  var logsCommand = async (config, args) => {
13228
14621
  chromeToStderr();
13229
14622
  const parsed = parseLogsArgs(args);
13230
14623
  if ("error" in parsed) return fail7(`${parsed.error} \xB7 ${usageOf("logs")}`);
13231
- const { session: word, follow, from, process: processPrefix } = parsed.args;
13232
- const session = word === null ? await resolveLiveSession(config, void 0, "logs") : await resolveAnySession(config, word);
13233
- const detail = await api(config, "GET", `/sessions/${session.id}`);
13234
- const picked = pickProcess(detail, processPrefix);
13235
- if ("error" in picked) return fail7(`session ${session.id.slice(0, 8)} \xB7 ${picked.error}`);
13236
- const target = picked.process;
13237
- if (target.id === void 0 || target.sealantSessionId === null) {
13238
- return fail7(
13239
- `session ${session.id.slice(0, 8)} \xB7 this process has no recorded terminal (an adopted port, or a server older than process ids)`
13240
- );
14624
+ const { session: word, follow, from, process: processPrefix, service } = parsed.args;
14625
+ let target;
14626
+ if (service !== null) {
14627
+ target = await serviceLogsTarget(config, service, word);
14628
+ } else {
14629
+ const session = word === null ? await resolveLiveSession(config, void 0, "logs") : await resolveAnySession(config, word);
14630
+ const id8 = session.id.slice(0, 8);
14631
+ const detail = await api(config, "GET", `/sessions/${session.id}`);
14632
+ const picked = pickProcess(detail, processPrefix);
14633
+ if ("error" in picked) {
14634
+ const asService = processPrefix === null ? null : pickServiceAttempt(await fetchServiceViews(config, true), processPrefix, session.id);
14635
+ if (asService === null || "error" in asService && !asService.named) {
14636
+ return fail7(`session ${id8} \xB7 ${picked.error}`);
14637
+ }
14638
+ if ("error" in asService) return fail7(`session ${id8} \xB7 ${asService.error}`);
14639
+ target = {
14640
+ processId: asService.processId,
14641
+ again: `mend logs ${id8} --process ${processPrefix}`
14642
+ };
14643
+ } else {
14644
+ const chosen = picked.process;
14645
+ if (chosen.id === void 0 || chosen.sealantSessionId === null) {
14646
+ return fail7(
14647
+ `session ${id8} \xB7 this process has no recorded terminal (an adopted port, or a server older than process ids)`
14648
+ );
14649
+ }
14650
+ target = {
14651
+ processId: chosen.id,
14652
+ again: processPrefix === null ? `mend logs ${id8}` : `mend logs ${id8} --process ${processPrefix}`
14653
+ };
14654
+ }
13241
14655
  }
13242
14656
  stopOutputOnSignal(
13243
- follow ? `stopped watching \xB7 the process keeps running \xB7 mend logs ${session.id.slice(0, 8)} --follow` : "stopped reading the record"
14657
+ follow ? `stopped watching \xB7 the process keeps running \xB7 ${target.again} --follow` : "stopped reading the record"
13244
14658
  );
13245
14659
  try {
13246
- const last = await writeProcessLogs(config, target.id, from, follow);
14660
+ const last = await writeProcessLogs(config, target.processId, from, follow);
13247
14661
  restoreTerminal();
13248
14662
  if (follow) say7(dim7(`
13249
14663
  record ended \xB7 ${last.status} \xB7 next sequence ${last.next}`));
@@ -13260,7 +14674,7 @@ var waitCommand = async (config, args) => {
13260
14674
  if ("error" in parsed) return fail7(`${parsed.error} \xB7 ${usageOf("wait")}`);
13261
14675
  const { session: word, timeoutMs, json: json2, process: processPrefix } = parsed.args;
13262
14676
  const deadline = timeoutMs === null ? null : clock.now() + timeoutMs;
13263
- const after = `${Math.round((timeoutMs ?? 0) / 1e3)} s`;
14677
+ const after = durationLine(timeoutMs ?? 0);
13264
14678
  const timedOut = async (session2, processId2, last) => {
13265
14679
  say7(
13266
14680
  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`
@@ -13577,7 +14991,7 @@ var modelsCommand = async (config, args) => {
13577
14991
  "GET",
13578
14992
  "/harnesses/models"
13579
14993
  );
13580
- if (args.includes("--json")) return say7(modelCatalogJson(catalogs));
14994
+ if (args.includes("--json")) return printJson(modelCatalogJson(catalogs));
13581
14995
  for (const line of modelCatalogLines(catalogs, dim7)) say7(line);
13582
14996
  };
13583
14997
  var sessionsCommand = async (config, args) => {
@@ -13585,7 +14999,7 @@ var sessionsCommand = async (config, args) => {
13585
14999
  if (args.includes("--json=v2")) {
13586
15000
  const projectFlag2 = args.indexOf("--project");
13587
15001
  const projectName2 = projectFlag2 !== -1 && args[projectFlag2 + 1] !== void 0 ? String(args[projectFlag2 + 1]) : null;
13588
- say7(JSON.stringify(await buildWorktreesJson(config, projectName2), null, 2));
15002
+ printJson(await buildWorktreesJson(config, projectName2));
13589
15003
  return;
13590
15004
  }
13591
15005
  const json2 = args.includes("--json");
@@ -13624,7 +15038,7 @@ var sessionsCommand = async (config, args) => {
13624
15038
  }
13625
15039
  if (rows.length === 0) {
13626
15040
  if (json2) {
13627
- say7(JSON.stringify({ version: 1, sessions: [] }, null, 2));
15041
+ printJson({ version: 1, sessions: [] });
13628
15042
  return;
13629
15043
  }
13630
15044
  say7(
@@ -13670,7 +15084,7 @@ var sessionsCommand = async (config, args) => {
13670
15084
  }
13671
15085
  }))
13672
15086
  };
13673
- say7(JSON.stringify(payload, null, 2));
15087
+ printJson(payload);
13674
15088
  return;
13675
15089
  }
13676
15090
  const workspaceLines = await workspaceLinesOf(
@@ -13766,7 +15180,7 @@ var worktreesCommand = async (config, args) => {
13766
15180
  const projectName = projectFlag !== -1 && args[projectFlag + 1] !== void 0 ? String(args[projectFlag + 1]) : null;
13767
15181
  const payload = await buildWorktreesJson(config, projectName);
13768
15182
  if (json2) {
13769
- say7(JSON.stringify(payload, null, 2));
15183
+ printJson(payload);
13770
15184
  return;
13771
15185
  }
13772
15186
  if (payload.worktrees.length === 0) {
@@ -13822,7 +15236,7 @@ var dashboard = async (config, options = {}) => {
13822
15236
  );
13823
15237
  process.exit(rerun.status ?? 0);
13824
15238
  }
13825
- const { runDashboard } = await import("./dashboard-DQB5RNXJ.js");
15239
+ const { runDashboard } = await import("./dashboard-OBFWHSPD.js");
13826
15240
  const agentShare = await startShareIfBridge(config);
13827
15241
  const tunnels = options.noTunnel === true || serverIsLocal(config) ? null : createServiceTunnels({
13828
15242
  listServices: () => fetchServices(config),
@@ -13897,9 +15311,9 @@ var manCommand = (words) => {
13897
15311
  const version = cliVersion();
13898
15312
  const page = doc === null ? renderManIndex(version) : renderManPage(doc, version);
13899
15313
  const file = path21.join(os13.tmpdir(), `mend-man-${process.pid}-${manFileName(doc)}`);
13900
- fs20.writeFileSync(file, page);
15314
+ fs21.writeFileSync(file, page);
13901
15315
  const result = spawnSync6("man", ["-l", file], { stdio: "inherit" });
13902
- fs20.rmSync(file, { force: true });
15316
+ fs21.rmSync(file, { force: true });
13903
15317
  if (result.error !== void 0 || result.status !== 0) {
13904
15318
  say7(doc === null ? renderIndex() : renderCommand(doc));
13905
15319
  }
@@ -13945,7 +15359,7 @@ var main = async () => {
13945
15359
  case "uninstall":
13946
15360
  return uninstallCommand(config, rest);
13947
15361
  case "keys":
13948
- return keysCommand(config, rest);
15362
+ return keysCommand2(config, rest);
13949
15363
  case "git-author":
13950
15364
  return gitAuthorCommand(config, rest);
13951
15365
  case "dotfiles":