@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.
- package/npm/cli.js +5 -1
- package/npm/cloud.js +2079 -242
- package/npm/codesign-plan.js +68 -0
- package/npm/compose-conflict.js +59 -0
- package/npm/empty-project-dir.js +92 -7
- package/npm/fleet-add-failure.js +361 -0
- package/npm/fleet-asset-version.js +37 -0
- package/npm/fleet-auth-health.js +143 -5
- package/npm/fleet-build-data.js +60 -5
- package/npm/fleet-cleanup.js +243 -9
- package/npm/fleet-destroy-safety.js +230 -13
- package/npm/fleet-early-roster.js +87 -0
- package/npm/fleet-effect-poll.js +110 -0
- package/npm/fleet-env.js +86 -0
- package/npm/fleet-ghcr-secret.js +129 -0
- package/npm/fleet-git-maintenance.js +142 -0
- package/npm/fleet-host-op-limiter.js +68 -0
- package/npm/fleet-iap-token.js +165 -0
- package/npm/fleet-job-badge.js +28 -12
- package/npm/fleet-live-status.js +40 -8
- package/npm/fleet-memory.js +289 -9
- package/npm/fleet-plans.js +98 -4
- package/npm/fleet-pool.js +75 -16
- package/npm/fleet-prewarm.js +87 -11
- package/npm/fleet-probe-transport.js +155 -0
- package/npm/fleet-project-drift.js +58 -0
- package/npm/fleet-public-url.js +234 -0
- package/npm/fleet-queue.js +58 -1
- package/npm/fleet-reachability-debounce.js +69 -0
- package/npm/fleet-reachability.js +80 -0
- package/npm/fleet-reset.js +32 -0
- package/npm/fleet-revision.js +104 -0
- package/npm/fleet-running-plan.js +115 -0
- package/npm/fleet-session-token-cache.js +65 -0
- package/npm/fleet-setup-steps.js +19 -12
- package/npm/fleet-status-refs.js +96 -0
- package/npm/fleet-strand.js +39 -8
- package/npm/fleet-stuck-status.js +13 -7
- package/npm/fleet-vm-access.js +170 -0
- package/npm/fleet-vm-probe-target.js +282 -0
- package/npm/fleet-vm-readiness.js +136 -3
- package/npm/google-ingress.js +15 -0
- package/npm/ingress-invoke.js +478 -0
- package/npm/launch-config.js +302 -10
- package/npm/scenario-check.js +318 -12
- package/npm/scenario-handlers.js +85 -27
- package/npm/scenario-interactivity.js +145 -6
- package/npm/scenario-issues.js +13 -1
- package/npm/scenario-mocks.js +129 -26
- package/npm/scenario-playwright.js +330 -22
- package/npm/script-drift.js +72 -1
- package/npm/utils.js +37 -3
- package/package.json +5 -5
- package/ui/dist/assets/{ContainerInspector-D7VZkkaj.js → ContainerInspector-Cu1tSN-o.js} +1 -1
- package/ui/dist/assets/EditorShell--V2ExYQ6.js +260 -0
- package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
- package/ui/dist/assets/{Markdown-BoLHHFXH.js → Markdown-Chgdf3PW.js} +1 -1
- package/ui/dist/assets/ProjectLauncherScreen-Dmm6Z0mv.js +1 -0
- package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
- package/ui/dist/assets/ScenarioDataPanel-DjprZq2L.js +9 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-CBLTGC4-.js → ScenarioDataPanelFullPage-BqMxWVKW.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-DqLVVMYS.js +21 -0
- package/ui/dist/assets/{StepsSimulator-BFldWyoX.js → StepsSimulator-CymGgfbu.js} +1 -1
- package/ui/dist/assets/index-CjxWhkSy.css +1 -0
- package/ui/dist/assets/{index-QsOiOG9x.js → index-VuyqBbGJ.js} +4 -4
- package/ui/dist/assets/{markdown-v0F0UEt3.js → markdown-C9tbsVHX.js} +1 -1
- package/ui/dist/assets/react-nrLBr15I.js +28 -0
- package/ui/dist/assets/{useEvents-DHkLJZR4.js → useEvents-S3Qk1Y_d.js} +1 -1
- package/ui/dist/assets/useServerIdentity-BdXy8TQv.js +1 -0
- package/ui/dist/index.html +4 -4
- package/ui/dist/open-source/codeyam-counter.png +0 -0
- package/ui/dist/open-source/el-carot.png +0 -0
- package/ui/dist/open-source/tabcommand.png +0 -0
- package/ui/dist/assets/EditorShell-DHhIaotd.css +0 -1
- package/ui/dist/assets/EditorShell-r-796N_W.js +0 -191
- package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +0 -1
- package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +0 -5
- package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +0 -21
- package/ui/dist/assets/index-CIXZkwfB.css +0 -1
- package/ui/dist/assets/react-CSS0HapR.js +0 -28
package/npm/launch-config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
703
|
-
|
|
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
|
|
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
|
|
810
|
-
* `pkill -f "<previewPort>:127.0.0.1:"`
|
|
811
|
-
*
|
|
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;
|
|
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,
|