@codeyam-editor/codeyam-editor 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/npm/cli.js +5 -1
  2. package/npm/cloud.js +2079 -242
  3. package/npm/codesign-plan.js +68 -0
  4. package/npm/compose-conflict.js +59 -0
  5. package/npm/empty-project-dir.js +92 -7
  6. package/npm/fleet-add-failure.js +361 -0
  7. package/npm/fleet-asset-version.js +37 -0
  8. package/npm/fleet-auth-health.js +143 -5
  9. package/npm/fleet-build-data.js +60 -5
  10. package/npm/fleet-cleanup.js +243 -9
  11. package/npm/fleet-destroy-safety.js +230 -13
  12. package/npm/fleet-early-roster.js +87 -0
  13. package/npm/fleet-effect-poll.js +110 -0
  14. package/npm/fleet-env.js +86 -0
  15. package/npm/fleet-ghcr-secret.js +129 -0
  16. package/npm/fleet-git-maintenance.js +142 -0
  17. package/npm/fleet-host-op-limiter.js +68 -0
  18. package/npm/fleet-iap-token.js +165 -0
  19. package/npm/fleet-job-badge.js +28 -12
  20. package/npm/fleet-live-status.js +40 -8
  21. package/npm/fleet-memory.js +289 -9
  22. package/npm/fleet-plans.js +98 -4
  23. package/npm/fleet-pool.js +75 -16
  24. package/npm/fleet-prewarm.js +87 -11
  25. package/npm/fleet-probe-transport.js +155 -0
  26. package/npm/fleet-project-drift.js +58 -0
  27. package/npm/fleet-public-url.js +234 -0
  28. package/npm/fleet-queue.js +58 -1
  29. package/npm/fleet-reachability-debounce.js +69 -0
  30. package/npm/fleet-reachability.js +80 -0
  31. package/npm/fleet-reset.js +32 -0
  32. package/npm/fleet-revision.js +104 -0
  33. package/npm/fleet-running-plan.js +115 -0
  34. package/npm/fleet-session-token-cache.js +65 -0
  35. package/npm/fleet-setup-steps.js +19 -12
  36. package/npm/fleet-status-refs.js +96 -0
  37. package/npm/fleet-strand.js +39 -8
  38. package/npm/fleet-stuck-status.js +13 -7
  39. package/npm/fleet-vm-access.js +170 -0
  40. package/npm/fleet-vm-probe-target.js +282 -0
  41. package/npm/fleet-vm-readiness.js +136 -3
  42. package/npm/google-ingress.js +15 -0
  43. package/npm/ingress-invoke.js +478 -0
  44. package/npm/launch-config.js +302 -10
  45. package/npm/scenario-check.js +318 -12
  46. package/npm/scenario-handlers.js +85 -27
  47. package/npm/scenario-interactivity.js +145 -6
  48. package/npm/scenario-issues.js +13 -1
  49. package/npm/scenario-mocks.js +129 -26
  50. package/npm/scenario-playwright.js +330 -22
  51. package/npm/script-drift.js +72 -1
  52. package/npm/utils.js +37 -3
  53. package/package.json +5 -5
  54. package/ui/dist/assets/{ContainerInspector-D7VZkkaj.js → ContainerInspector-Cu1tSN-o.js} +1 -1
  55. package/ui/dist/assets/EditorShell--V2ExYQ6.js +260 -0
  56. package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
  57. package/ui/dist/assets/{Markdown-BoLHHFXH.js → Markdown-Chgdf3PW.js} +1 -1
  58. package/ui/dist/assets/ProjectLauncherScreen-Dmm6Z0mv.js +1 -0
  59. package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
  60. package/ui/dist/assets/ScenarioDataPanel-DjprZq2L.js +9 -0
  61. package/ui/dist/assets/{ScenarioDataPanelFullPage-CBLTGC4-.js → ScenarioDataPanelFullPage-BqMxWVKW.js} +1 -1
  62. package/ui/dist/assets/ScenarioRenderer-DqLVVMYS.js +21 -0
  63. package/ui/dist/assets/{StepsSimulator-BFldWyoX.js → StepsSimulator-CymGgfbu.js} +1 -1
  64. package/ui/dist/assets/index-CjxWhkSy.css +1 -0
  65. package/ui/dist/assets/{index-QsOiOG9x.js → index-VuyqBbGJ.js} +4 -4
  66. package/ui/dist/assets/{markdown-v0F0UEt3.js → markdown-C9tbsVHX.js} +1 -1
  67. package/ui/dist/assets/react-nrLBr15I.js +28 -0
  68. package/ui/dist/assets/{useEvents-DHkLJZR4.js → useEvents-S3Qk1Y_d.js} +1 -1
  69. package/ui/dist/assets/useServerIdentity-BdXy8TQv.js +1 -0
  70. package/ui/dist/index.html +4 -4
  71. package/ui/dist/open-source/codeyam-counter.png +0 -0
  72. package/ui/dist/open-source/el-carot.png +0 -0
  73. package/ui/dist/open-source/tabcommand.png +0 -0
  74. package/ui/dist/assets/EditorShell-DHhIaotd.css +0 -1
  75. package/ui/dist/assets/EditorShell-r-796N_W.js +0 -191
  76. package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +0 -1
  77. package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +0 -5
  78. package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +0 -21
  79. package/ui/dist/assets/index-CIXZkwfB.css +0 -1
  80. package/ui/dist/assets/react-CSS0HapR.js +0 -28
@@ -119,6 +119,52 @@ function validateLaunchConfig(cfg) {
119
119
  return { ok: true };
120
120
  }
121
121
 
122
+ /** Pre-flight the operator-host CONFIG a dashboard Add depends on, BEFORE
123
+ * spawning the ~20-min cloud:up. Pure classifier: it takes the ALREADY-resolved
124
+ * zone, project, and GitHub token (the side-effecting resolution — env lookups,
125
+ * `gh auth token`, gcloud defaults — is the caller's job) and returns { ok, msg }
126
+ * naming the exact fix for the FIRST blocking gap. This turns a config fault into
127
+ * a seconds-fast, actionable add-rejection instead of a cryptic `cloud:up FAILED
128
+ * (exit 1)` the operator has to dig out of a 20-min job log.
129
+ *
130
+ * It guards the two config faults that silently broke every fleet add on
131
+ * 2026-07-20:
132
+ * - no resolvable compute zone → cloud:up aborts at its own --zone check in ~15s
133
+ * before creating any instance;
134
+ * - no GitHub token → the fresh VM's bootstrap dies cloning the PRIVATE editor
135
+ * repo at `could not read Username for 'https://github.com'`.
136
+ * `allowNoGitCredentials` mirrors cloud:up's --allow-no-git-credentials opt-out
137
+ * for an intentionally read-only VM (no push token needed). Pure; no I/O. */
138
+ function addPreflight({ zone, project, token, allowNoGitCredentials = false } = {}) {
139
+ const blank = (v) => typeof v !== "string" || v.trim() === "";
140
+ if (blank(zone)) {
141
+ return {
142
+ ok: false,
143
+ msg:
144
+ "no compute zone configured — set ZONE in /etc/codeyam/operator-host.env " +
145
+ "(or `gcloud config set compute/zone <zone>`) before adding a VM.",
146
+ };
147
+ }
148
+ if (blank(project)) {
149
+ return {
150
+ ok: false,
151
+ msg:
152
+ "no GCP project configured — set GCP_PROJECT (or a non-placeholder " +
153
+ "project in .codeyam/cloud.json) before adding a VM.",
154
+ };
155
+ }
156
+ if (!allowNoGitCredentials && blank(token)) {
157
+ return {
158
+ ok: false,
159
+ msg:
160
+ "no GitHub token — set GITHUB_TOKEN/GH_TOKEN in /etc/codeyam/operator-host.env " +
161
+ "(the VM bootstrap needs it to clone the private editor repo), or provision a " +
162
+ "read-only VM with --allow-no-git-credentials.",
163
+ };
164
+ }
165
+ return { ok: true };
166
+ }
167
+
122
168
  /** Gate mirror of cloud.js::assertVariantProvisionable — which editor variants
123
169
  * can be provisioned onto a cloud VM. All three variants now provision: `dev`
124
170
  * builds the editor image from source, while `staging`/`production` run the
@@ -186,8 +232,10 @@ function isPlaceholderProject(value) {
186
232
  * image from editorBranch); the client repo/branch is what lands in
187
233
  * /workspace. The build agent rides on `cfg.agent` (normalizeLaunchConfig
188
234
  * guarantees a known value, defaulting to claude), so a single config object
189
- * carries every launch choice. Decoupled exactly as cloud.js consumes them. */
190
- function cloudUpArgs(cfg, instanceName, project) {
235
+ * carries every launch choice. Decoupled exactly as cloud.js consumes them.
236
+ * `zone` is the resolved compute zone (the dashboard's ZONE); passing it
237
+ * explicitly is what keeps an add off the ambient gcloud default. */
238
+ function cloudUpArgs(cfg, instanceName, project, zone) {
191
239
  const args = ["--instance-name", instanceName];
192
240
  // Pass the authoritative GCP project (the dashboard's resolved PROJECT)
193
241
  // explicitly so cloud:up never relies on the spawning shell's ambient
@@ -200,6 +248,13 @@ function cloudUpArgs(cfg, instanceName, project) {
200
248
  // preserve the pre-existing arg shape (cloud.js then falls back to the gcloud
201
249
  // default, as before).
202
250
  if (project) args.push("--project", project);
251
+ // Pass the resolved compute zone explicitly, for the SAME reason as --project:
252
+ // a bare cloud:up otherwise falls back to `gcloud config get-value
253
+ // compute/zone`, and an operator host with no default compute/zone aborts
254
+ // EVERY add at cloud:up's own --zone check in ~15s, before any instance is
255
+ // created (the 2026-07-20 fleet-wide add failure). Omitted only when the caller
256
+ // resolves no zone, preserving the pre-existing arg shape for legacy callers.
257
+ if (zone) args.push("--zone", zone);
203
258
  // Greenfield (new folder) vs clone an existing repo.
204
259
  if (cfg.newProjectName) args.push("--new-project", cfg.newProjectName);
205
260
  else args.push("--repo", cfg.clientRepo);
@@ -497,6 +552,61 @@ function stickyAppPort(prevAppPort, info) {
497
552
  return typeof prevAppPort === 'number' && !Number.isNaN(prevAppPort) ? prevAppPort : null;
498
553
  }
499
554
 
555
+ /** Resolve a VM's preview-tunnel reachability stickily, mirroring `stickyAppPort`.
556
+ * A single flaky preview probe must not blink the 👁 Live Preview link off:
557
+ * an explicit `true`/`false` from the fresh poll passes through (a genuine
558
+ * dead/alive transition always wins), but a missing / null / undefined
559
+ * `info.previewReachable` (transient poll — the probe was skipped, timed out,
560
+ * or the whole probe object is absent) retains the last-known-good
561
+ * `prevPreviewReachable` instead of blanking it to null. `null` results only
562
+ * when neither a fresh boolean nor a previous boolean exists — the true
563
+ * never-probed first-poll case. Pure; no I/O.
564
+ *
565
+ * @param prevPreviewReachable the previous `state.vms[n].previewReachable`
566
+ * @param info the fresh probe result ({ previewReachable } | null)
567
+ * @returns true | false | null per the rules above. */
568
+ function stickyPreviewReachable(prevPreviewReachable, info) {
569
+ const fresh = info && info.previewReachable;
570
+ if (fresh === true || fresh === false) return fresh;
571
+ return prevPreviewReachable === true || prevPreviewReachable === false
572
+ ? prevPreviewReachable
573
+ : null;
574
+ }
575
+
576
+ /** Resolve a VM's preview-SERVING signal — "is the VM's dev server up?" —
577
+ * stickily from the `previewState` field the editor already emits on
578
+ * `/api/session-info`. This is the operator-host-safe replacement for the
579
+ * loopback-only `previewReachable` probe: session-info reaches every VM over
580
+ * the IAP-LB seam on the GCE operator host, where the `127.0.0.1` preview
581
+ * forward `previewReachable` depended on cannot exist (so it read `false`
582
+ * fleet-wide there). It measures the SAME thing the old loopback probe did —
583
+ * "the dev server serves" — not public LB ingress; that is `previewIngress`'s job.
584
+ *
585
+ * `compute_preview_state` classifies it authoritatively from inside the VM
586
+ * (immune to tunnel flakiness, with a recompile-grace debounce):
587
+ * `"forwarding"` → true (dev server up / serving).
588
+ * `"placeholder"` → false (dev server down or never started).
589
+ * anything else → sticky: a missing field (legacy editor predating
590
+ * `previewState`, or `info` null on a transient/failed
591
+ * poll) retains the last-known-good `prevPreviewServing`
592
+ * rather than blinking the 👁 link off. `null` only when
593
+ * neither a fresh state nor a previous boolean exists —
594
+ * the true never-probed first-poll case. Pure; no I/O.
595
+ *
596
+ * Mirrors `stickyPreviewReachable`'s tri-state stickiness exactly.
597
+ *
598
+ * @param prevPreviewServing the previous `state.vms[n].previewServing`
599
+ * @param info the fresh `/api/session-info` result ({ previewState } | null)
600
+ * @returns true | false | null per the rules above. */
601
+ function stickyPreviewServing(prevPreviewServing, info) {
602
+ const state = info && info.previewState;
603
+ if (state === 'forwarding') return true;
604
+ if (state === 'placeholder') return false;
605
+ return prevPreviewServing === true || prevPreviewServing === false
606
+ ? prevPreviewServing
607
+ : null;
608
+ }
609
+
500
610
  /** Classify a VM's project KIND as `"self-host"` vs `"guest"` from the live
501
611
  * identity reported in `/api/session-info` (`clientProject.repo`), with
502
612
  * launch-history `clientRepo` as a fallback when live is absent. A guest is
@@ -674,6 +784,73 @@ function claudeAuthSeeded(mode) {
674
784
  // the server.js helper so callers don't have to thread one through.
675
785
  function _shq(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; }
676
786
 
787
+ // Assemble a `pkill -f` whose ERE is ANCHORED at the target's own executable:
788
+ // `^`, an optional directory prefix, then `exe`. Every sweep builder in this
789
+ // module routes through here so the anchor is structural rather than remembered
790
+ // — an unanchored `pkill -f` matches the FULL command line, which for the fleet
791
+ // dashboard's `sh -c '<add script>'` wrapper includes the script text itself.
792
+ // See the "Self-match-safe process sweeps" block below for the full failure
793
+ // mode. `argv` is an ERE fragment matched after the executable; escape any
794
+ // literal dots in it.
795
+ function _anchoredSweep({ exe, argv } = {}) {
796
+ return `pkill -f "^([^ ]*/)?${exe} ${argv}" 2>/dev/null`;
797
+ }
798
+
799
+ /** Build a `docker exec … bash -c` sink that stages stdin to a same-directory
800
+ * `.cytmp` sibling and only `mv`s it onto `target` when the staged file is
801
+ * NON-EMPTY — exiting 1 (so the caller's `… || echo CY_DEGRADED` fires) and
802
+ * leaving any existing `target` UNTOUCHED on empty/unreadable input.
803
+ *
804
+ * Closes the truncate-to-0 hazard the plan targets: an empty/unreadable donor's
805
+ * `base64 -d > /root/.claude/.credentials.json` used to clobber the live creds
806
+ * to a 0-byte file AND still report success, because the pipeline's exit status
807
+ * is the sink's, not the empty upstream's. This mirrors the non-empty `-s` guard
808
+ * the git-cred cache path already uses (scripts/cloud-editor-entrypoint.sh).
809
+ *
810
+ * `decode:true` base64-decodes stdin (the donor / host-stash cred copies);
811
+ * `decode:false` copies stdin verbatim (the Rebuild onboarding-state re-seed).
812
+ *
813
+ * `requireCredentialShape:true` adds, ON TOP of the non-empty guard, a Claude
814
+ * `.credentials.json` shape check before the `mv`: a positive plausible
815
+ * `expiresAt` (rejects the observed corrupt-write `expiresAt: 0`) AND non-empty
816
+ * access/refresh tokens. A non-empty-but-malformed donor (valid-looking file
817
+ * with a sentinel expiry or empty tokens) therefore never overwrites the
818
+ * target — closing the remaining gap the non-empty `[ -s ]` guard alone leaves.
819
+ * The checks are quote-free single-quoted ERE patterns (no `"`), which stay
820
+ * intact through the ssh→docker→`bash -c "…"` quoting layers without escaping.
821
+ * The editor-side self-heal (control-api `creds_refresh` / `auth_session`)
822
+ * remains the durable defense against a bad credential from ANY writer; this
823
+ * hardens the in-repo copy path so it is not itself a source.
824
+ *
825
+ * Deliberately uses NO shell variables or command substitution: a fixed
826
+ * `<target>.cytmp` sibling keeps the script free of `$var`, which the VM-host
827
+ * shell that runs the `gcloud … --command` would otherwise expand (to empty)
828
+ * before docker exec ever sees it. The `.cytmp` sibling sits in the same
829
+ * directory as `target`, so the `mv` is an atomic same-filesystem rename. */
830
+ function verifyBeforeMoveSink(
831
+ container,
832
+ target,
833
+ { decode = true, requireCredentialShape = false } = {},
834
+ ) {
835
+ const tmp = `${target}.cytmp`;
836
+ const read = decode ? "base64 -d" : "cat";
837
+ // Positive plausible `expiresAt` (13+ digit millis, first digit non-zero — so
838
+ // `0`/negatives/tiny values fail), plus non-empty access + refresh tokens
839
+ // (≥20 token-ish chars). `[^0-9-]*` / `[^A-Za-z0-9]*` bridge the `":"`
840
+ // punctuation without literal quotes; the value `0` in a corrupt file is a
841
+ // digit that blocks the skip, so the pattern can't false-match a later field.
842
+ const shape = requireCredentialShape
843
+ ? ` && grep -Eq 'expiresAt[^0-9-]*[1-9][0-9]{12,}' ${tmp}` +
844
+ ` && grep -Eq 'accessToken[^A-Za-z0-9]*[A-Za-z0-9_-]{20,}' ${tmp}` +
845
+ ` && grep -Eq 'refreshToken[^A-Za-z0-9]*[A-Za-z0-9_-]{20,}' ${tmp}`
846
+ : "";
847
+ const script =
848
+ `umask 077; ${read} > ${tmp}; ` +
849
+ `if [ -s ${tmp} ]${shape}; then mv ${tmp} ${target} && chmod 600 ${target}; ` +
850
+ `else rm -f ${tmp}; exit 1; fi`;
851
+ return `docker exec -i ${container} bash -c "${script}"`;
852
+ }
853
+
677
854
  /** Build the shell steps that seed a freshly-added VM's claude + git credentials,
678
855
  * for the resolved `mode` (see `resolveCredsSource`). Returns an array of shell
679
856
  * strings the caller newline-joins into the larger provisioning script — each
@@ -699,15 +876,22 @@ function _shq(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; }
699
876
  *
700
877
  * Pure; no I/O. */
701
878
  function buildCredsSteps({ mode, refInst = "", inst, container, gcloud, zone, n, claudeCredsSrc = "" } = {}) {
702
- const claudeSink = `docker exec -i ${container} bash -c "umask 077; base64 -d > /root/.claude/.credentials.json && chmod 600 /root/.claude/.credentials.json"`;
703
- const claudeJsonSink = `docker exec -i ${container} bash -c "umask 077; base64 -d > /root/.claude.json && chmod 600 /root/.claude.json"`;
879
+ // The `.credentials.json` copy additionally validates the donor's credential
880
+ // shape (positive `expiresAt`, non-empty tokens) before overwriting — a
881
+ // non-empty-but-corrupt donor must not clobber a working target. The
882
+ // `.claude.json` (onboarding-state) copy has a different shape, so it keeps
883
+ // the plain non-empty guard.
884
+ const claudeSink = verifyBeforeMoveSink(container, "/root/.claude/.credentials.json", {
885
+ requireCredentialShape: true,
886
+ });
887
+ const claudeJsonSink = verifyBeforeMoveSink(container, "/root/.claude.json");
704
888
  const loginHint = `run: docker exec -it ${container} claude /login on ${inst}`;
705
889
  if (mode === "donor") {
706
890
  return [
707
891
  `echo "copying git creds from ${refInst}..."`,
708
892
  `${gcloud} compute ssh ${refInst} --tunnel-through-iap --zone=${zone} --command='cat $HOME/.codeyam-cloud-git-credentials' 2>/dev/null | ${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} --command='umask 077; cat > $HOME/.codeyam-cloud-git-credentials' 2>/dev/null || echo "CY_DEGRADED git creds copy failed on VM-${n} — re-copy creds from a live donor (the agent can't git pull/push until then)"`,
709
893
  `echo "copying claude auth from ${refInst} (container->container)..."`,
710
- `${gcloud} compute ssh ${refInst} --tunnel-through-iap --zone=${zone} --command='docker exec ${container} base64 /root/.claude/.credentials.json' 2>/dev/null | ${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} --command=${_shq(claudeSink)} 2>/dev/null && echo "claude auth copied" || echo "CY_DEGRADED claude auth copy failed on VM-${n} — ${loginHint}"`,
894
+ `${gcloud} compute ssh ${refInst} --tunnel-through-iap --zone=${zone} --command='docker exec ${container} base64 /root/.claude/.credentials.json' 2>/dev/null | ${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} --command=${_shq(claudeSink)} 2>/dev/null && echo "claude auth copied" || echo "CY_DEGRADED claude auth copy failed on VM-${n} (empty/unreadable/malformed donor — existing creds left untouched, not overwritten with a corrupt credential) — ${loginHint}"`,
711
895
  `echo "copying claude.json (onboarding state) from ${refInst} (container->container)..."`,
712
896
  `${gcloud} compute ssh ${refInst} --tunnel-through-iap --zone=${zone} --command='docker exec ${container} base64 /root/.claude.json' 2>/dev/null | ${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} --command=${_shq(claudeJsonSink)} 2>/dev/null && echo "claude.json copied" || echo "CY_DEGRADED claude.json copy failed on VM-${n} — first interactive claude launch may show 'Select login method' menu; ${loginHint} once to complete onboarding"`,
713
897
  ];
@@ -758,7 +942,7 @@ function buildClaudeJsonPreserveSteps({ container, n = "", bakVar = "CY_CLAUDE_J
758
942
  `if docker exec ${container} cat /root/.claude.json > "$${bakVar}" 2>/dev/null && [ -s "$${bakVar}" ]; then echo "captured /root/.claude.json ($(wc -c < "$${bakVar}") bytes) to preserve onboarding state across recreate"; else echo "no /root/.claude.json to preserve (new container will use the image baseline)"; : > "$${bakVar}"; fi`,
759
943
  ];
760
944
  const restore = [
761
- `if [ -s "$${bakVar}" ]; then docker exec -i ${container} bash -c 'umask 077; cat > /root/.claude.json && chmod 600 /root/.claude.json' < "$${bakVar}" && echo "re-seeded /root/.claude.json (onboarding state preserved across recreate)" || echo "CY_DEGRADED claude.json re-seed failed on VM-${n} after Rebuild — first interactive claude launch may show 'Select login method'; ${loginHint} once to complete onboarding"; else echo "no preserved /root/.claude.json; ${loginHint} if the Build tab shows the welcome menu"; fi`,
945
+ `if [ -s "$${bakVar}" ]; then ${verifyBeforeMoveSink(container, "/root/.claude.json", { decode: false })} < "$${bakVar}" && echo "re-seeded /root/.claude.json (onboarding state preserved across recreate)" || echo "CY_DEGRADED claude.json re-seed failed on VM-${n} after Rebuild — first interactive claude launch may show 'Select login method'; ${loginHint} once to complete onboarding"; else echo "no preserved /root/.claude.json; ${loginHint} if the Build tab shows the welcome menu"; fi`,
762
946
  `rm -f "$${bakVar}"`,
763
947
  ];
764
948
  return { capture, restore };
@@ -806,13 +990,112 @@ function editorTunnelCmd({ gcloud, inst, zone, editorPort, n } = {}) {
806
990
  * from the editor reachability forward (`editorTunnelCmd`): a preview re-wire
807
991
  * pkills + respawns ONLY this process, leaving the editor :14199 forward — and
808
992
  * the reachability probe riding it — untouched. The preview local port is unique
809
- * per VM and never collides with the editor port, so the re-wire/destroy
810
- * `pkill -f "<previewPort>:127.0.0.1:"` targets exactly this process. Pure; no
811
- * I/O. */
993
+ * per VM and never collides with the editor port. Sweep it through
994
+ * `previewForwardSweepCmd` — the bare `pkill -f "<previewPort>:127.0.0.1:"` this
995
+ * comment used to call "targets exactly this process" did NOT: it also matched
996
+ * every `sh -c '<add script>'` wrapper carrying that literal. Pure; no I/O. */
812
997
  function previewTunnelCmd({ gcloud, inst, zone, previewPort, appPort, n } = {}) {
813
998
  return `nohup ${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} -- -N -L ${previewPort}:127.0.0.1:${appPort} > /tmp/ssh-preview-vm-${n}.log 2>&1 &`;
814
999
  }
815
1000
 
1001
+ /* ── Self-match-safe process sweeps ─────────────────────────────────────────
1002
+ *
1003
+ * Every builder below emits a `pkill -f` whose match expression is ANCHORED at
1004
+ * the TARGET's executable — a leading `^`, an optional directory prefix, then
1005
+ * the program name (`ssh`, `node`, `cloudflared`). That anchor is the entire
1006
+ * point of these builders, not decoration.
1007
+ *
1008
+ * `pkill -f` matches the FULL command line. The fleet dashboard runs an Add as
1009
+ * `/bin/sh -c '<the entire add script text>'`, and that script text EMBEDS these
1010
+ * very sweep lines — so an unanchored pattern like `pkill -f
1011
+ * "14199:127.0.0.1:14199"` is a substring of the wrapper's own argv. It SIGTERMs
1012
+ * the wrapper that is running it, and every concurrent SIBLING add wrapper too.
1013
+ * The wrapper then dies from an uncaught signal, so its EXIT trap never writes
1014
+ * the `add-<n>.exit` marker that `fc.readExitMarker` / `fc.reattachVerdict` exist
1015
+ * to consume; `startJob`'s exec callback reports a red "Add failed" on a VM that
1016
+ * is fine; and the detached `cloud:up` it started — whose own argv does NOT carry
1017
+ * the pattern — is orphaned to init and keeps provisioning, after which
1018
+ * stranded-reconcile declares the VM half-provisioned and stacks a SECOND
1019
+ * `cloud:up` on top of it. That is the 2026-07-31 VM-3..8 batch, whose containers
1020
+ * were Up and answering /api/health 200 throughout.
1021
+ *
1022
+ * Excluding `$$` / `$PPID` does NOT fix this: the processes being wrongly killed
1023
+ * are SIBLINGS, not self or parent. The pattern itself must be incapable of
1024
+ * matching a `sh -c '<script>'` command line, and anchoring at the executable is
1025
+ * what guarantees that — a wrapper's argv always begins with the shell, never
1026
+ * with `ssh` / `node` / `cloudflared`.
1027
+ *
1028
+ * Same trap, same lesson as `npm/cloud.js` bootstrapStatusCommand (:1446) and
1029
+ * projectBootstrapStatusCommand (:1760), which document it explicitly ("the
1030
+ * status command's own argv contains the bootstrap path, so a `pgrep -f` on it
1031
+ * would match this very probe") and dodge it with a PID file + `kill -0`. A PID
1032
+ * file cannot replace THESE sweeps: they deliberately target STRAY processes left
1033
+ * by a prior add, a crashed watchdog respawn, or an earlier container generation
1034
+ * — processes this script never owned and holds no PID for. So the sweep stays
1035
+ * pattern-based; only the pattern's blast radius changes.
1036
+ *
1037
+ * Keep every sweep in this family behind one of these builders. Re-inlining a
1038
+ * bare `pkill -f` at a call site is exactly how the bug got in the first time. */
1039
+
1040
+ /** Sweep a stray EDITOR reachability forward — the `-L <localPort>:127.0.0.1:14199`
1041
+ * ssh process `editorTunnelCmd` opens.
1042
+ *
1043
+ * Targets the `ssh` CHILD, not the `gcloud compute ssh` python parent. The child
1044
+ * is what binds the local listening socket, so killing it is what actually
1045
+ * releases the port; the parent is only waiting on it and exits on its own once
1046
+ * it goes. Only the child's argv starts with `ssh`, which is also what makes the
1047
+ * anchor safe. Pure; no I/O. */
1048
+ function editorForwardSweepCmd({ localPort } = {}) {
1049
+ return _anchoredSweep({ exe: "ssh", argv: `.*-L ${localPort}:127\\.0\\.0\\.1:14199` });
1050
+ }
1051
+
1052
+ /** Sweep a stray LIVE-PREVIEW forward — the `-L <localPort>:127.0.0.1:<appPort>`
1053
+ * ssh process `previewTunnelCmd` opens. The remote port is left open because the
1054
+ * app's dev-server port legitimately drifts; the per-VM preview local port is the
1055
+ * identifying half and never collides with the editor port. Same ssh-child
1056
+ * targeting as `editorForwardSweepCmd`. Pure; no I/O. */
1057
+ function previewForwardSweepCmd({ localPort } = {}) {
1058
+ return _anchoredSweep({ exe: "ssh", argv: `.*-L ${localPort}:127\\.0\\.0\\.1:` });
1059
+ }
1060
+
1061
+ /** Sweep a wedged `cloud:up` provision for one instance.
1062
+ *
1063
+ * Targets the `node npm/cloud.js up --instance-name <inst>` process, NOT the
1064
+ * `npm run cloud:up` parent — call sites already hold the npm child's PID and
1065
+ * `kill` it directly; this reaps the node grandchild that PID does not cover.
1066
+ * `--instance-name` is the first pair `cloudUpArgs` emits, so the literal stays
1067
+ * adjacent to `up`. Pure; no I/O. */
1068
+ function cloudUpSweepCmd({ inst } = {}) {
1069
+ return _anchoredSweep({ exe: "node", argv: `.*cloud\\.js up --instance-name ${inst}` });
1070
+ }
1071
+
1072
+ /** Sweep the cloudflared quick-tunnel fronting a VM's editor port.
1073
+ *
1074
+ * Anchored for the same reason as its siblings, with one extra bite: this sweep
1075
+ * runs inside a `sh -c` compound built by `exec()`, so unanchored it matched its
1076
+ * OWN parent shell — killing the compound before the editor/preview sweeps that
1077
+ * follow it on the same line ever ran. Pure; no I/O. */
1078
+ function cloudflaredTunnelSweepCmd({ localPort } = {}) {
1079
+ return _anchoredSweep({ exe: "cloudflared", argv: `tunnel --url http://localhost:${localPort}` });
1080
+ }
1081
+
1082
+ /** Build the destroy-time local reap compound for one VM: free the editor port,
1083
+ * then sweep the cloudflared quick-tunnel and BOTH split ssh forwards.
1084
+ *
1085
+ * Lives here rather than inline in `cleanLocalVmArtifacts` because the ORDERING
1086
+ * is load-bearing and was previously untestable: the whole compound runs as one
1087
+ * `sh -c`, so the unanchored cloudflared sweep used to match that shell's own
1088
+ * argv and kill it BEFORE the two forward sweeps behind it ever ran — meaning a
1089
+ * destroy silently orphaned both forwards. Pure; no I/O. */
1090
+ function localVmReapCmd({ port, previewPort } = {}) {
1091
+ return [
1092
+ `lsof -ti:${port} 2>/dev/null | xargs -r kill`,
1093
+ cloudflaredTunnelSweepCmd({ localPort: port }),
1094
+ editorForwardSweepCmd({ localPort: port }),
1095
+ previewForwardSweepCmd({ localPort: previewPort }),
1096
+ ].join("; ");
1097
+ }
1098
+
816
1099
  /** Build the shell snippet the Add script runs to AWAIT cloud:up's clean exit at
817
1100
  * container-healthy instead of reaping it mid-flight.
818
1101
  *
@@ -837,7 +1120,7 @@ function awaitWiringCmd({ inst, deadlineSec = 180, pidVar = "UP_PID" } = {}) {
837
1120
  `while kill -0 $${pidVar} 2>/dev/null; do`,
838
1121
  ` if [ "$__cy_wiring_waited" -ge ${deadlineSec} ]; then`,
839
1122
  ` echo "WARN cloud:up still running after ${deadlineSec}s (wedged before the container-healthy early return) — reaping it (the dashboard self-heal will converge any half-wired backend)"`,
840
- ` kill $${pidVar} 2>/dev/null; pkill -f "cloud.js up --instance-name ${inst}" 2>/dev/null; break`,
1123
+ ` kill $${pidVar} 2>/dev/null; ${cloudUpSweepCmd({ inst })}; break`,
841
1124
  ` fi`,
842
1125
  ` sleep 5; __cy_wiring_waited=$((__cy_wiring_waited + 5))`,
843
1126
  `done`,
@@ -890,6 +1173,7 @@ module.exports = {
890
1173
  EMPTY_CONFIG,
891
1174
  normalizeLaunchConfig,
892
1175
  validateLaunchConfig,
1176
+ addPreflight,
893
1177
  isVariantProvisionable,
894
1178
  isVariantRebuildable,
895
1179
  variantNpmDistTag,
@@ -909,10 +1193,13 @@ module.exports = {
909
1193
  resolveCardIdentity,
910
1194
  stickyClientProject,
911
1195
  stickyAppPort,
1196
+ stickyPreviewReachable,
1197
+ stickyPreviewServing,
912
1198
  classifyProjectKind,
913
1199
  resolveCredsSource,
914
1200
  claudeAuthSeeded,
915
1201
  buildCredsSteps,
1202
+ verifyBeforeMoveSink,
916
1203
  buildClaudeJsonPreserveSteps,
917
1204
  buildEnvApplyCommand,
918
1205
  resolveEditorSource,
@@ -921,6 +1208,11 @@ module.exports = {
921
1208
  decideStartJob,
922
1209
  editorTunnelCmd,
923
1210
  previewTunnelCmd,
1211
+ editorForwardSweepCmd,
1212
+ previewForwardSweepCmd,
1213
+ cloudUpSweepCmd,
1214
+ cloudflaredTunnelSweepCmd,
1215
+ localVmReapCmd,
924
1216
  awaitWiringCmd,
925
1217
  getAuthRepoUrl,
926
1218
  interpretLsRemote,