@codeyam-editor/codeyam-editor 0.1.2 → 0.1.3
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/cloud.js +494 -86
- package/npm/editor-dev.js +133 -52
- package/npm/editor.js +35 -17
- package/npm/fleet-branch.js +49 -0
- package/npm/fleet-broker-death.js +44 -0
- package/npm/fleet-broker-debounce.js +63 -0
- package/npm/fleet-broker-status.js +45 -0
- package/npm/fleet-cleanup.js +189 -16
- package/npm/fleet-effect-status.js +179 -0
- package/npm/fleet-grouping.js +56 -0
- package/npm/fleet-job-badge.js +39 -0
- package/npm/fleet-memory.js +35 -5
- package/npm/fleet-reset.js +94 -16
- package/npm/launch-config.js +88 -10
- package/npm/preflight-guidance.js +9 -0
- package/npm/scenario-check.js +25 -0
- package/npm/scenario-interactivity.js +53 -7
- package/npm/scenario-playwright.js +184 -5
- package/npm/utils.js +100 -46
- package/package.json +5 -5
- package/ui/dist/assets/EditorShell-CGsBSA6u.js +186 -0
- package/ui/dist/assets/ScenarioDataPanel-nuLXJzLf.js +5 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-C0YHQB-T.js → ScenarioDataPanelFullPage-Bnghtj8a.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-B_Za9jL8.js +1 -0
- package/ui/dist/assets/index-Bih6HJ_u.css +1 -0
- package/ui/dist/assets/{index-BUYNFqKK.js → index-KDFAgt2e.js} +2 -2
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/EditorShell-DWaeBxgj.js +0 -186
- package/ui/dist/assets/ScenarioDataPanel-zJSXtXL4.js +0 -5
- package/ui/dist/assets/ScenarioRenderer-CgDxuigm.js +0 -1
- package/ui/dist/assets/index-DVr5rY2d.css +0 -1
package/npm/cloud.js
CHANGED
|
@@ -110,11 +110,11 @@ const CLOUDFLARED_URLS_FILE =
|
|
|
110
110
|
// image out of the client repo.
|
|
111
111
|
const CANONICAL_EDITOR_REPO = "https://github.com/codeyam-ai/codeyam-editor.git";
|
|
112
112
|
|
|
113
|
-
// The editor distribution channels an operator can pick per VM
|
|
113
|
+
// The editor distribution channels an operator can pick per VM. All three are
|
|
114
|
+
// provisionable (gcp-bootstrap dispatches on --editor-variant):
|
|
114
115
|
// dev — build the editor image from the editor repo @ editorBranch
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
// production — `npm i -g @codeyam-editor/codeyam-editor` (Phase 2)
|
|
116
|
+
// staging — run the published `@codeyam-editor/codeyam-editor@staging` image
|
|
117
|
+
// production — run the published `@codeyam-editor/codeyam-editor` (latest) image
|
|
118
118
|
const EDITOR_VARIANTS = ["dev", "staging", "production"];
|
|
119
119
|
|
|
120
120
|
/** Map an agent name to its API-key env var. Throws on unknown agents so
|
|
@@ -302,33 +302,98 @@ function resolveSeedPreviewOrigin(
|
|
|
302
302
|
urls = {},
|
|
303
303
|
appPort = VITE_PORT,
|
|
304
304
|
env = process.env,
|
|
305
|
+
sameOriginSafe = true,
|
|
305
306
|
) {
|
|
307
|
+
// Same-origin-safe stack (the default — Vite et al.): ALWAYS serve the app
|
|
308
|
+
// same-origin under the reserved relative path, in every fleet mode. The
|
|
309
|
+
// editor mounts `/__codeyam_preview` on its own origin and forwards to the
|
|
310
|
+
// in-VM app-port reverse proxy, so the iframe loads from the exact origin the
|
|
311
|
+
// editor is served on (public quick-tunnel, named tunnel, SSH-tunnel
|
|
312
|
+
// localhost, anything). It is a CONSTANT relative path: it never rotates and
|
|
313
|
+
// depends only on the editor's own origin being reachable, so it can never rot
|
|
314
|
+
// the way a frozen managed quick-tunnel URL does (the VM-1/2/6 leftover this
|
|
315
|
+
// plan fixes). No `_managedPreviewOrigin` marker is stamped — the marker is
|
|
316
|
+
// reserved for the genuinely-managed dedicated origins below, so the self-heal
|
|
317
|
+
// predicate can tell a rotting managed URL apart from this constant path.
|
|
318
|
+
if (sameOriginSafe) {
|
|
319
|
+
return { previewOrigin: SAME_ORIGIN_PREVIEW_PATH };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// NOT same-origin-safe (client-routed stacks — Next.js etc.): the client
|
|
323
|
+
// router breaks the same-origin subpath proxy (it pushState's to root-absolute
|
|
324
|
+
// paths and recurses into the editor), so the preview must come from a
|
|
325
|
+
// DEDICATED origin. We stamp `_managedPreviewOrigin` so the (legacy) monitor
|
|
326
|
+
// recognises the value as ours and won't mistake it for a user pin.
|
|
306
327
|
const vm = resolveFleetVmNumber(instanceName);
|
|
307
328
|
const domain = resolveFleetDomain(env);
|
|
308
|
-
// Named
|
|
309
|
-
//
|
|
329
|
+
// Named-tunnel mode: the deterministic `vm-<N>.preview.<domain>` host is a
|
|
330
|
+
// dedicated sibling subdomain of the editor and never rotates. A non-fleet
|
|
331
|
+
// client project (vm == null) keeps the localhost seed even with a domain set.
|
|
310
332
|
if (domain && vm != null) {
|
|
311
333
|
return {
|
|
312
334
|
previewOrigin: vmUrls(vm, domain).preview,
|
|
313
335
|
_managedPreviewOrigin: MANAGED_PREVIEW_ORIGIN,
|
|
314
336
|
};
|
|
315
337
|
}
|
|
316
|
-
// Quick-tunnel / domain-free mode:
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
338
|
+
// Quick-tunnel / domain-free mode: prefer the operator's published
|
|
339
|
+
// `<N>-preview` quick-tunnel URL (the public origin a remote browser can
|
|
340
|
+
// actually load), else the SSH-tunnel bootstrap localhost origin
|
|
341
|
+
// (`http://localhost:<appPort>`, reached via the laptop `-L` forward). Never
|
|
342
|
+
// the same-origin subpath — that is exactly what recurses for this stack.
|
|
343
|
+
const previewKey = vm != null ? `${vm}-preview` : null;
|
|
344
|
+
const tunnelUrl =
|
|
345
|
+
previewKey &&
|
|
346
|
+
typeof urls[previewKey] === "string" &&
|
|
347
|
+
urls[previewKey].length > 0
|
|
348
|
+
? urls[previewKey]
|
|
349
|
+
: `http://localhost:${appPort}`;
|
|
326
350
|
return {
|
|
327
|
-
previewOrigin:
|
|
351
|
+
previewOrigin: tunnelUrl,
|
|
328
352
|
_managedPreviewOrigin: MANAGED_PREVIEW_ORIGIN,
|
|
329
353
|
};
|
|
330
354
|
}
|
|
331
355
|
|
|
356
|
+
/** Extract the stack's `preview.sameOriginSafe` flag from a parsed
|
|
357
|
+
* `.codeyam/stack.json` object. Absent / non-object / non-boolean ⇒ `true`
|
|
358
|
+
* (the same-origin default), mirroring `PreviewConfig::same_origin_safe()` on
|
|
359
|
+
* the Rust side so the seeder and the editor agree on the truth table. Pure. */
|
|
360
|
+
function previewSameOriginSafeFromStack(stackJson) {
|
|
361
|
+
const preview =
|
|
362
|
+
stackJson && typeof stackJson === "object" ? stackJson.preview : null;
|
|
363
|
+
const flag =
|
|
364
|
+
preview && typeof preview === "object" ? preview.sameOriginSafe : undefined;
|
|
365
|
+
return typeof flag === "boolean" ? flag : true;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Matches an absolute cloudflared quick-tunnel origin
|
|
369
|
+
// (`https://<sub>.trycloudflare.com`, optionally with a port/path/query). These
|
|
370
|
+
// URLs ROTATE on every tunnel respawn, which is exactly why a frozen one rots.
|
|
371
|
+
const TRYCLOUDFLARE_ORIGIN_RE =
|
|
372
|
+
/^https:\/\/[a-z0-9-]+\.trycloudflare\.com(?:[:/?#]|$)/i;
|
|
373
|
+
|
|
374
|
+
/** Decide whether a VM's current `editor.local.json` carries a ROTTED managed
|
|
375
|
+
* cloudflared previewOrigin that must be self-healed back to the same-origin
|
|
376
|
+
* path. True iff ALL hold:
|
|
377
|
+
* - the stack is same-origin-safe (a client-routed stack legitimately keeps a
|
|
378
|
+
* dedicated origin — never heal it);
|
|
379
|
+
* - the value is stamped `_managedPreviewOrigin: cloudflared-monitor` (WE
|
|
380
|
+
* wrote it, not a user pin); and
|
|
381
|
+
* - the `previewOrigin` is an absolute `*.trycloudflare.com` quick-tunnel URL
|
|
382
|
+
* (the rotating kind that goes dead — a deterministic named-tunnel host or
|
|
383
|
+
* the constant `/__codeyam_preview` path is left alone).
|
|
384
|
+
* Idempotent by construction: a VM already on `/__codeyam_preview` (or any
|
|
385
|
+
* non-trycloudflare origin) returns false, so re-running the heal is a no-op.
|
|
386
|
+
* Pure — `localJson` is a parsed editor.local.json object. */
|
|
387
|
+
function isRottedManagedPreviewOrigin(localJson, sameOriginSafe) {
|
|
388
|
+
if (!sameOriginSafe) return false;
|
|
389
|
+
if (!localJson || typeof localJson !== "object") return false;
|
|
390
|
+
if (localJson._managedPreviewOrigin !== MANAGED_PREVIEW_ORIGIN) return false;
|
|
391
|
+
return (
|
|
392
|
+
typeof localJson.previewOrigin === "string" &&
|
|
393
|
+
TRYCLOUDFLARE_ORIGIN_RE.test(localJson.previewOrigin)
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
332
397
|
/** Read the operator-laptop cloudflared-urls.json sidecar. Missing/unparseable
|
|
333
398
|
* → {} (the monitor may not have run yet; the seed falls back to localhost). */
|
|
334
399
|
function readCloudflaredUrls() {
|
|
@@ -370,26 +435,33 @@ function validateEditorVariant(variant) {
|
|
|
370
435
|
return variant;
|
|
371
436
|
}
|
|
372
437
|
|
|
373
|
-
/**
|
|
374
|
-
*
|
|
375
|
-
*
|
|
438
|
+
/** Assert a variant can be provisioned onto a cloud VM. Every known variant is
|
|
439
|
+
* provisionable now — `dev` builds the editor image from source, while
|
|
440
|
+
* `staging`/`production` run the published npm-package image (gcp-bootstrap
|
|
441
|
+
* pulls/builds Dockerfile.npm keyed off the variant). So this collapses to the
|
|
442
|
+
* unknown-variant guard: a typo'd `--editor-variant` fails loud rather than
|
|
443
|
+
* silently launching the wrong (or no) image. */
|
|
376
444
|
function assertVariantProvisionable(variant) {
|
|
377
|
-
validateEditorVariant(variant);
|
|
378
|
-
if (variant !== "dev") {
|
|
379
|
-
throw new Error(
|
|
380
|
-
`Editor variant '${variant}' is not yet wired for cloud provisioning ` +
|
|
381
|
-
`(Phase 2 — the @staging/production npm-install image). Use --editor-variant dev.`,
|
|
382
|
-
);
|
|
383
|
-
}
|
|
384
|
-
return variant;
|
|
445
|
+
return validateEditorVariant(variant);
|
|
385
446
|
}
|
|
386
447
|
|
|
387
|
-
/** Args for scripts/gcp-bootstrap.sh, which
|
|
388
|
-
* image
|
|
448
|
+
/** Args for scripts/gcp-bootstrap.sh, which produces the codeyam-editor:local
|
|
449
|
+
* image. For the `dev` variant that image is built from the EDITOR source
|
|
450
|
+
* (--repo/--branch); for packaged variants (staging/production) it comes from
|
|
451
|
+
* the published npm package, so the editor source repo/branch is irrelevant and
|
|
452
|
+
* omitted — gcp-bootstrap pulls/builds Dockerfile.npm keyed off --editor-variant.
|
|
453
|
+
* The CLIENT project repo/branch is unaffected: it flows through cloud:up's own
|
|
454
|
+
* project bootstrap, not this function. */
|
|
389
455
|
function editorImageBootstrapArgs(cfg) {
|
|
390
|
-
const { editorRepo, editorBranch } = resolveEditorConfig(cfg);
|
|
391
|
-
const args = ["--agent", cfg.agent, "--
|
|
392
|
-
|
|
456
|
+
const { editorRepo, editorBranch, editorVariant } = resolveEditorConfig(cfg);
|
|
457
|
+
const args = ["--agent", cfg.agent, "--editor-variant", editorVariant];
|
|
458
|
+
// Stack assumption: only the `dev` variant builds the editor image from source,
|
|
459
|
+
// so only it needs the editor repo/branch plumbing gcp-bootstrap's source path
|
|
460
|
+
// consumes. Packaged variants install the binary from the published npm package.
|
|
461
|
+
if (editorVariant === "dev") {
|
|
462
|
+
args.push("--repo", editorRepo);
|
|
463
|
+
if (editorBranch) args.push("--branch", editorBranch);
|
|
464
|
+
}
|
|
393
465
|
return args;
|
|
394
466
|
}
|
|
395
467
|
|
|
@@ -568,10 +640,19 @@ function buildGcloudArgs(verb, cfg = {}, extras = {}) {
|
|
|
568
640
|
// shell-quoting through gcloud → ssh → remote bash. The script body
|
|
569
641
|
// is delivered as one argument; the wrapper feeds the base64 into
|
|
570
642
|
// `extras.command` and the caller sets `input` to the script body.
|
|
643
|
+
//
|
|
644
|
+
// Keepalive flags are defense-in-depth for the long-running bootstrap
|
|
645
|
+
// follow session: a quiet stretch during `cargo build` (no stdout for
|
|
646
|
+
// minutes) must not trip an idle disconnect. ServerAliveInterval pings
|
|
647
|
+
// the VM every 30s and ServerAliveCountMax tolerates 6 missed replies
|
|
648
|
+
// (~3 min) before giving up, while TCPKeepAlive keeps NAT mappings warm.
|
|
649
|
+
// The detached server-side runner (bootstrapLaunchCommand) makes a real
|
|
650
|
+
// drop non-fatal; keepalive just makes spurious drops rarer.
|
|
571
651
|
return [
|
|
572
652
|
"compute", "ssh", instanceName,
|
|
573
653
|
"--project", project, "--zone", zone,
|
|
574
654
|
"--tunnel-through-iap",
|
|
655
|
+
...SSH_KEEPALIVE_FLAGS,
|
|
575
656
|
"--command", extras.command,
|
|
576
657
|
];
|
|
577
658
|
case "ssh-tunnel": {
|
|
@@ -807,19 +888,194 @@ function resolveSetupForwardEnv(env = process.env) {
|
|
|
807
888
|
return out;
|
|
808
889
|
}
|
|
809
890
|
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
891
|
+
// --ssh-flag values gcloud forwards verbatim to the underlying `ssh`. Shaped as
|
|
892
|
+
// `-o Key=Value` so they survive the gcloud → ssh hop unchanged. Used by the
|
|
893
|
+
// ssh-script verb (the long bootstrap follow session) — short ssh-probe calls
|
|
894
|
+
// don't need them. See the ssh-script case in buildGcloudArgs for the rationale.
|
|
895
|
+
const SSH_KEEPALIVE_FLAGS = [
|
|
896
|
+
"--ssh-flag=-o ServerAliveInterval=30",
|
|
897
|
+
"--ssh-flag=-o ServerAliveCountMax=6",
|
|
898
|
+
"--ssh-flag=-o TCPKeepAlive=yes",
|
|
899
|
+
];
|
|
900
|
+
|
|
901
|
+
// Canonical VM-side paths for the detached bootstrap. The build runs from
|
|
902
|
+
// BOOTSTRAP_RUNNER_PATH (a tiny wrapper that execs the real script), streams to
|
|
903
|
+
// BOOTSTRAP_LOG_PATH, and writes its exit status into BOOTSTRAP_DONE_PATH on
|
|
904
|
+
// completion. Completion + success are derived from these files (plus the
|
|
905
|
+
// docker image), NOT from the SSH exit code — so a dropped follow-session over
|
|
906
|
+
// a healthy VM is no longer misreported as "failed on the VM".
|
|
907
|
+
const BOOTSTRAP_SCRIPT_PATH = "$HOME/.codeyam-bootstrap.sh";
|
|
908
|
+
const BOOTSTRAP_RUNNER_PATH = "$HOME/.codeyam-bootstrap-run.sh";
|
|
909
|
+
const BOOTSTRAP_LOG_PATH = "$HOME/.codeyam-bootstrap.log";
|
|
910
|
+
const BOOTSTRAP_DONE_PATH = "$HOME/.codeyam-bootstrap.done";
|
|
911
|
+
const BOOTSTRAP_PID_PATH = "$HOME/.codeyam-bootstrap.pid";
|
|
912
|
+
const BOOTSTRAP_LAUNCHED_MARKER = "CODEYAM_BOOTSTRAP_LAUNCHED";
|
|
913
|
+
const BOOTSTRAP_STATUS_MARKER = "CYBOOT";
|
|
914
|
+
// improve40 — VM-side path of the PROJECT-bootstrap terminal completion marker.
|
|
915
|
+
// gcp-project-bootstrap.sh writes this as its very last step (only reached after
|
|
916
|
+
// `set -euo pipefail` carried every provisioning step to success), and
|
|
917
|
+
// ensureProjectBootstrapped's done-probe gates on it so a half-finished
|
|
918
|
+
// bootstrap is correctly re-run instead of false-skipped (the VM-7 incident).
|
|
919
|
+
// Lives outside the project's git tree (a $HOME dotfile, keyed by repo name) so
|
|
920
|
+
// it never churns the repo. Pure so the bash writer and the JS probe derive the
|
|
921
|
+
// EXACT same path; tested without a live VM.
|
|
922
|
+
function projectBootstrapDoneMarker(repoName) {
|
|
923
|
+
return `$HOME/.codeyam-project-bootstrap-${repoName}.done`;
|
|
924
|
+
}
|
|
925
|
+
// improve40 — the VM-side shell `test` chain ensureProjectBootstrapped runs to
|
|
926
|
+
// decide "is this project already bootstrapped?". Gates on the terminal `.done`
|
|
927
|
+
// marker FIRST (the VM-7 false-skip fix — both structural artifacts can exist on
|
|
928
|
+
// a half-done bootstrap), then keeps the `.codeyam/` + docker-compose.cloud.yml
|
|
929
|
+
// checks as belt-and-suspenders for a legacy VM whose marker predates this change.
|
|
930
|
+
// Pure (just the command string) so the gating is unit-testable without gcloud.
|
|
931
|
+
function projectBootstrapDoneProbeCommand(repoName) {
|
|
932
|
+
return (
|
|
933
|
+
`test -f ${projectBootstrapDoneMarker(repoName)} ` +
|
|
934
|
+
`&& test -d ~/projects/${repoName}/.codeyam ` +
|
|
935
|
+
`&& test -f ~/projects/${repoName}/docker-compose.cloud.yml`
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
// One follow session caps at 30 min (comfortably over the ~14-min source
|
|
939
|
+
// build); the overall wait caps at 40 min across re-attaches before we bail
|
|
940
|
+
// loudly with the VM-side log location.
|
|
941
|
+
const BOOTSTRAP_FOLLOW_SECS = 1800;
|
|
942
|
+
const BOOTSTRAP_DEADLINE_MS = 40 * 60 * 1000;
|
|
943
|
+
|
|
944
|
+
// Quote a shell value the same way scriptArgs are quoted: single-quote it and
|
|
945
|
+
// escape any embedded apostrophe. Shared by the env-prefix and arg encoders so
|
|
946
|
+
// a token with an exotic character can't break out of its quoting.
|
|
947
|
+
function shQuote(v) {
|
|
948
|
+
return `'${String(v).replace(/'/g, "'\\''")}'`;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// Encode env vars as a shell-assignment PREFIX (`KEY='value' …`). Assignment
|
|
952
|
+
// prefixes set the variable in the launched command's environment only — never
|
|
953
|
+
// in its argv — so tokens stay out of `ps` on the VM. Empty / nullish values
|
|
954
|
+
// are dropped so the no-token path is unchanged.
|
|
955
|
+
function envAssignmentPrefix(envVars) {
|
|
956
|
+
const s = Object.entries(envVars)
|
|
819
957
|
.filter(([, v]) => v !== undefined && v !== null && v !== "")
|
|
820
|
-
.map(([k, v]) => `${k}
|
|
958
|
+
.map(([k, v]) => `${k}=${shQuote(v)}`)
|
|
821
959
|
.join(" ");
|
|
822
|
-
|
|
960
|
+
return s ? `${s} ` : "";
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// The tiny VM-side wrapper the detached launch execs. It runs the real
|
|
964
|
+
// bootstrap, redirects all output to the log, and records the exit status in
|
|
965
|
+
// the sentinel — the single source of truth for "did the build finish, and
|
|
966
|
+
// did it succeed?" once the SSH follow-session is gone. Contains NO secrets
|
|
967
|
+
// (tokens arrive via the launch env prefix, never written to disk).
|
|
968
|
+
function bootstrapRunnerScript() {
|
|
969
|
+
return [
|
|
970
|
+
"#!/usr/bin/env bash",
|
|
971
|
+
"# Detached bootstrap runner (written by npm/cloud.js ensureBootstrapped).",
|
|
972
|
+
"# Survives SSH disconnect: the laptop only *follows* the log below; this",
|
|
973
|
+
"# process keeps running under its own session if the follow drops.",
|
|
974
|
+
"# Records its own PID so the status probe can check liveness via `kill -0`",
|
|
975
|
+
"# (a `pgrep` on the script path would also match the probe command itself).",
|
|
976
|
+
`echo "$$" > "$HOME/.codeyam-bootstrap.pid"`,
|
|
977
|
+
`bash "$HOME/.codeyam-bootstrap.sh" "$@" > "$HOME/.codeyam-bootstrap.log" 2>&1`,
|
|
978
|
+
"status=$?",
|
|
979
|
+
`echo "$status" > "$HOME/.codeyam-bootstrap.done"`,
|
|
980
|
+
`rm -f "$HOME/.codeyam-bootstrap.pid"`,
|
|
981
|
+
"",
|
|
982
|
+
].join("\n");
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// Build the remote command that (1) materializes the bootstrap script + runner
|
|
986
|
+
// on the VM, (2) clears any stale sentinel, (3) launches the runner DETACHED
|
|
987
|
+
// (`nohup setsid`, new session, stdio to /dev/null) so it outlives the SSH
|
|
988
|
+
// session, and (4) prints a launched marker. The detach is the durable fix:
|
|
989
|
+
// the build no longer dies when the laptop's SSH pipe breaks.
|
|
990
|
+
function bootstrapLaunchCommand(scriptBody, scriptArgs = [], envVars = {}) {
|
|
991
|
+
const scriptB64 = Buffer.from(scriptBody, "utf8").toString("base64");
|
|
992
|
+
const runnerB64 = Buffer.from(bootstrapRunnerScript(), "utf8").toString("base64");
|
|
993
|
+
const argsString = scriptArgs.map(shQuote).join(" ");
|
|
994
|
+
const envPrefix = envAssignmentPrefix(envVars);
|
|
995
|
+
// Writes run synchronously (fast, before any backgrounding) so a decode error
|
|
996
|
+
// aborts before launch; then the runner is detached. The env prefix is on the
|
|
997
|
+
// setsid command so the tokens are inherited through nohup→setsid→bash but
|
|
998
|
+
// never appear in any argv. `& echo MARKER` (no `;` before echo) keeps the
|
|
999
|
+
// launch in the background while still confirming it fired.
|
|
1000
|
+
const writes = [
|
|
1001
|
+
`echo '${scriptB64}' | base64 -d > ${BOOTSTRAP_SCRIPT_PATH}`,
|
|
1002
|
+
`echo '${runnerB64}' | base64 -d > ${BOOTSTRAP_RUNNER_PATH}`,
|
|
1003
|
+
`rm -f ${BOOTSTRAP_DONE_PATH}`,
|
|
1004
|
+
].join(" && ");
|
|
1005
|
+
const launch =
|
|
1006
|
+
`${envPrefix}nohup setsid bash ${BOOTSTRAP_RUNNER_PATH} ${argsString}`.trimEnd() +
|
|
1007
|
+
` </dev/null >/dev/null 2>&1 &`;
|
|
1008
|
+
return `${writes}; ${launch} echo ${BOOTSTRAP_LAUNCHED_MARKER}`;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// Build the remote command that FOLLOWS the detached build: tail the log live
|
|
1012
|
+
// for the operator, polling for the sentinel, and exit when the build finishes
|
|
1013
|
+
// OR a per-session deadline elapses. If the SSH session drops mid-follow this
|
|
1014
|
+
// command dies but the detached build keeps going — the caller then re-attaches.
|
|
1015
|
+
function bootstrapFollowCommand(deadlineSecs = BOOTSTRAP_FOLLOW_SECS) {
|
|
1016
|
+
return [
|
|
1017
|
+
`touch ${BOOTSTRAP_LOG_PATH}`,
|
|
1018
|
+
`tail -n +1 -F ${BOOTSTRAP_LOG_PATH} 2>/dev/null & TP=$!`,
|
|
1019
|
+
`waited=0`,
|
|
1020
|
+
`while [ ! -f ${BOOTSTRAP_DONE_PATH} ]; do ` +
|
|
1021
|
+
`sleep 3; waited=$((waited+3)); ` +
|
|
1022
|
+
`if [ "$waited" -ge ${deadlineSecs} ]; then kill "$TP" 2>/dev/null; exit 0; fi; ` +
|
|
1023
|
+
`done`,
|
|
1024
|
+
`sleep 1`,
|
|
1025
|
+
`kill "$TP" 2>/dev/null`,
|
|
1026
|
+
].join("; ");
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// Build the remote command that reports terminal VM state in one parseable
|
|
1030
|
+
// line: whether the image exists, the sentinel's exit code (or `none`), and
|
|
1031
|
+
// whether a bootstrap process is still alive. Cheap — runs over ssh-probe.
|
|
1032
|
+
function bootstrapStatusCommand() {
|
|
1033
|
+
// Liveness via the runner's PID file + `kill -0`, NOT pgrep: the status
|
|
1034
|
+
// command's own argv contains the bootstrap path, so a `pgrep -f` on it would
|
|
1035
|
+
// match this very probe and always report running. A stale PID file from a
|
|
1036
|
+
// killed runner reads as not-running because `kill -0` fails.
|
|
1037
|
+
return [
|
|
1038
|
+
`img=absent; docker image inspect codeyam-editor:local >/dev/null 2>&1 && img=present`,
|
|
1039
|
+
`done=none; [ -f ${BOOTSTRAP_DONE_PATH} ] && done=$(cat ${BOOTSTRAP_DONE_PATH} 2>/dev/null)`,
|
|
1040
|
+
`run=no; if [ -f ${BOOTSTRAP_PID_PATH} ]; then p=$(cat ${BOOTSTRAP_PID_PATH} 2>/dev/null); ` +
|
|
1041
|
+
`{ [ -n "$p" ] && kill -0 "$p" 2>/dev/null; } && run=yes; fi`,
|
|
1042
|
+
`echo "${BOOTSTRAP_STATUS_MARKER} img=$img done=$done running=$run"`,
|
|
1043
|
+
].join("; ");
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// Parse the single status line emitted by bootstrapStatusCommand. Returns
|
|
1047
|
+
// `{ img, done, running }` or null when no status line is present (e.g. the
|
|
1048
|
+
// probe ssh itself failed and produced no stdout).
|
|
1049
|
+
function parseBootstrapStatus(stdout) {
|
|
1050
|
+
const m = String(stdout || "").match(
|
|
1051
|
+
new RegExp(`${BOOTSTRAP_STATUS_MARKER} img=(\\S+) done=(\\S+) running=(\\S+)`),
|
|
1052
|
+
);
|
|
1053
|
+
if (!m) return null;
|
|
1054
|
+
return { img: m[1], done: m[2], running: m[3] };
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// Decide the bootstrap outcome from VM-side state alone — never the SSH exit
|
|
1058
|
+
// code. The image is the real artifact, so its presence is success regardless
|
|
1059
|
+
// of how the follow session ended. A live process means "re-attach". A sentinel
|
|
1060
|
+
// without an image means the build ran and failed. Nothing at all means it was
|
|
1061
|
+
// never started (or the probe couldn't see it).
|
|
1062
|
+
function decideBootstrapOutcome(status) {
|
|
1063
|
+
if (!status) return "absent";
|
|
1064
|
+
const { img, done, running } = status;
|
|
1065
|
+
if (img === "present") return "success";
|
|
1066
|
+
if (running === "yes") return "running";
|
|
1067
|
+
if (done && done !== "none") return "failed";
|
|
1068
|
+
return "absent";
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function sshScriptCommand(scriptBody, scriptArgs = [], envVars = {}) {
|
|
1072
|
+
const b64 = Buffer.from(scriptBody, "utf8").toString("base64");
|
|
1073
|
+
// Args + env share the quoting/encoding helpers with bootstrapLaunchCommand:
|
|
1074
|
+
// single-quoted args (shQuote) and a `KEY='value'` assignment prefix
|
|
1075
|
+
// (envAssignmentPrefix) that sets the vars for the `bash -s` process only —
|
|
1076
|
+
// no persistence in the VM's shell history, no argv exposure of tokens.
|
|
1077
|
+
const argsString = scriptArgs.map(shQuote).join(" ");
|
|
1078
|
+
const envPrefix = envAssignmentPrefix(envVars);
|
|
823
1079
|
return `echo '${b64}' | base64 -d | ${envPrefix}bash -s -- ${argsString}`.trim();
|
|
824
1080
|
}
|
|
825
1081
|
|
|
@@ -850,42 +1106,124 @@ async function ensureBootstrapped(cfg) {
|
|
|
850
1106
|
// wrapper / Dockerfile patches from a feature branch; without it the image
|
|
851
1107
|
// build uses the editor repo's default branch.
|
|
852
1108
|
const bootstrapArgs = editorImageBootstrapArgs(cfg);
|
|
853
|
-
//
|
|
854
|
-
//
|
|
855
|
-
//
|
|
856
|
-
//
|
|
857
|
-
//
|
|
858
|
-
//
|
|
859
|
-
|
|
1109
|
+
// Token threading into the bootstrap env (assignment prefix, never argv):
|
|
1110
|
+
// CODEYAM_GHCR_TOKEN — read:packages, for `docker login` + the PRIVATE
|
|
1111
|
+
// prebuilt base/binary PULLS. Without it every pull 403s and the
|
|
1112
|
+
// bootstrap slow-builds (~14 min) from source.
|
|
1113
|
+
// CODEYAM_GHCR_PUSH_TOKEN — write:packages, used ONLY by the post-compile
|
|
1114
|
+
// publish step's push. Distinct from the read token because a push needs
|
|
1115
|
+
// a strictly broader scope; reusing the read token guarantees a 403.
|
|
1116
|
+
// Absent → the bootstrap skips the publish cleanly (no doomed push).
|
|
1117
|
+
// CODEYAM_GHCR_USER / CODEYAM_GHCR_PUSH_USER — optional login-username
|
|
1118
|
+
// overrides. Empty values are dropped, so the no-registry path is
|
|
1119
|
+
// unchanged.
|
|
1120
|
+
const bootstrapEnv = {
|
|
860
1121
|
GITHUB_TOKEN: resolveGithubToken(),
|
|
861
1122
|
CODEYAM_GHCR_TOKEN: resolveGhcrToken(),
|
|
862
1123
|
CODEYAM_GHCR_USER: process.env.CODEYAM_GHCR_USER || "",
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
1124
|
+
CODEYAM_GHCR_PUSH_TOKEN: resolveGhcrPushToken(),
|
|
1125
|
+
CODEYAM_GHCR_PUSH_USER: process.env.CODEYAM_GHCR_PUSH_USER || "",
|
|
1126
|
+
};
|
|
1127
|
+
|
|
1128
|
+
// Idempotent re-attach: a prior cloud:up whose follow-session dropped may
|
|
1129
|
+
// have left a detached build still running (or already finished). Converge
|
|
1130
|
+
// on it instead of starting over.
|
|
1131
|
+
const initial = probeBootstrapOutcome(cfg);
|
|
1132
|
+
if (initial === "success") {
|
|
1133
|
+
console.error(" ✓ a previous detached bootstrap already completed");
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
if (initial === "running") {
|
|
1137
|
+
console.error(" ↺ a detached bootstrap is already in progress — re-attaching to its log...");
|
|
1138
|
+
} else {
|
|
1139
|
+
// "absent" (never started) OR "failed" (a prior run died) → (re)launch a
|
|
1140
|
+
// fresh detached build that will survive an SSH disconnect.
|
|
1141
|
+
console.error(" launching detached bootstrap on the VM (survives SSH disconnect; ~1-14 min)...");
|
|
1142
|
+
const launch = bootstrapLaunchCommand(scriptBody, bootstrapArgs, bootstrapEnv);
|
|
1143
|
+
const launched = runGcloud(
|
|
1144
|
+
buildGcloudArgs("ssh-script", cfg, { command: launch }),
|
|
1145
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
1146
|
+
);
|
|
1147
|
+
if (launched.status !== 0) {
|
|
1148
|
+
throw new Error("failed to launch the detached bootstrap on the VM");
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// Follow the live log, re-attaching across any follow-session drops, until
|
|
1153
|
+
// the VM reports a terminal state or the overall deadline elapses. Success
|
|
1154
|
+
// and failure are read from VM state (image + sentinel), never the SSH exit
|
|
1155
|
+
// code — so "my laptop's SSH dropped" no longer masquerades as a build error.
|
|
1156
|
+
const deadline = Date.now() + BOOTSTRAP_DEADLINE_MS;
|
|
1157
|
+
for (;;) {
|
|
1158
|
+
runGcloud(
|
|
1159
|
+
buildGcloudArgs("ssh-script", cfg, { command: bootstrapFollowCommand() }),
|
|
1160
|
+
{ stdio: "inherit" },
|
|
1161
|
+
);
|
|
1162
|
+
const outcome = probeBootstrapOutcome(cfg);
|
|
1163
|
+
if (outcome === "success") {
|
|
1164
|
+
console.error(" ✓ VM bootstrap complete");
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
if (outcome === "failed") {
|
|
1168
|
+
const tail = bootstrapLogTail(cfg);
|
|
1169
|
+
throw new Error(
|
|
1170
|
+
"scripts/gcp-bootstrap.sh failed on the VM. " +
|
|
1171
|
+
`Last lines of ~/.codeyam-bootstrap.log:\n${tail}`,
|
|
1172
|
+
);
|
|
1173
|
+
}
|
|
1174
|
+
if (Date.now() >= deadline) {
|
|
1175
|
+
throw new Error(
|
|
1176
|
+
"Timed out waiting for the VM bootstrap. It may still be running — " +
|
|
1177
|
+
"re-run cloud:up to re-attach, or inspect ~/.codeyam-bootstrap.log on the VM.",
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
// outcome === "running": the follow session ended but the build is alive.
|
|
1181
|
+
console.error(" ↺ follow session ended but the build is still running — re-attaching...");
|
|
870
1182
|
}
|
|
871
1183
|
}
|
|
872
1184
|
|
|
1185
|
+
// Probe the VM for the detached bootstrap's terminal state and reduce it to a
|
|
1186
|
+
// single outcome string ("success" | "failed" | "running" | "absent"). Used
|
|
1187
|
+
// both for the idempotent re-attach decision and after each follow session.
|
|
1188
|
+
function probeBootstrapOutcome(cfg) {
|
|
1189
|
+
const r = runGcloud(
|
|
1190
|
+
buildGcloudArgs("ssh-probe", cfg, { command: bootstrapStatusCommand() }),
|
|
1191
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
1192
|
+
);
|
|
1193
|
+
return decideBootstrapOutcome(parseBootstrapStatus(r.stdout));
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// Fetch the tail of the VM-side bootstrap log for an actionable failure
|
|
1197
|
+
// message — the operator sees WHY the build failed, not a bare exit code.
|
|
1198
|
+
function bootstrapLogTail(cfg, lines = 40) {
|
|
1199
|
+
const r = runGcloud(
|
|
1200
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
1201
|
+
command: `tail -n ${lines} ${BOOTSTRAP_LOG_PATH} 2>/dev/null`,
|
|
1202
|
+
}),
|
|
1203
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
1204
|
+
);
|
|
1205
|
+
return String(r.stdout || "").trim() || "(log unavailable)";
|
|
1206
|
+
}
|
|
1207
|
+
|
|
873
1208
|
async function ensureProjectBootstrapped(cfg) {
|
|
874
1209
|
const repoName = resolveProject(cfg).name;
|
|
875
1210
|
console.error(`Checking project bootstrap state for ${repoName}...`);
|
|
876
|
-
//
|
|
877
|
-
//
|
|
878
|
-
//
|
|
879
|
-
//
|
|
880
|
-
//
|
|
881
|
-
//
|
|
882
|
-
// The
|
|
883
|
-
// the
|
|
1211
|
+
// Gate on the TERMINAL completion marker the script writes as its very last
|
|
1212
|
+
// step (scripts/gcp-project-bootstrap.sh → ~/.codeyam-project-bootstrap-<repo>.done),
|
|
1213
|
+
// not on intermediate structural artifacts. Earlier this probed only `.codeyam/`
|
|
1214
|
+
// + `docker-compose.cloud.yml` — but BOTH can exist while the bootstrap is
|
|
1215
|
+
// still incomplete (init scaffolds `.codeyam/` and the compose render happens
|
|
1216
|
+
// mid-script, before the creds/claude-dir setup and `npm install`/`setup`
|
|
1217
|
+
// finish). The improve40 VM-7 incident hit exactly this: a recovery re-run saw
|
|
1218
|
+
// the two markers, declared "already bootstrapped", and SKIPPED the unfinished
|
|
1219
|
+
// work. The `.done` marker is written only after `set -euo pipefail` has
|
|
1220
|
+
// carried every step to success, so an interrupted bootstrap lacks it and is
|
|
1221
|
+
// correctly re-run (the re-run is idempotent — skip-on-exists per step). The
|
|
1222
|
+
// two structural checks stay as belt-and-suspenders for a legacy VM whose
|
|
1223
|
+
// marker predates this change but is otherwise wired.
|
|
884
1224
|
const probe = runGcloud(
|
|
885
1225
|
buildGcloudArgs("ssh-probe", cfg, {
|
|
886
|
-
command:
|
|
887
|
-
`test -d ~/projects/${repoName}/.codeyam ` +
|
|
888
|
-
`&& test -f ~/projects/${repoName}/docker-compose.cloud.yml`,
|
|
1226
|
+
command: projectBootstrapDoneProbeCommand(repoName),
|
|
889
1227
|
}),
|
|
890
1228
|
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
891
1229
|
);
|
|
@@ -991,7 +1329,7 @@ function resolveGithubToken() {
|
|
|
991
1329
|
return "";
|
|
992
1330
|
}
|
|
993
1331
|
|
|
994
|
-
/** A `read:packages
|
|
1332
|
+
/** A PULL-ONLY (`read:packages`) token the VM bootstrap uses to `docker login
|
|
995
1333
|
* ghcr.io` and pull the PRIVATE prebuilt cloud-smoke base + branch-binary
|
|
996
1334
|
* packages. Prefer an explicit CODEYAM_GHCR_TOKEN (the operator's scoped
|
|
997
1335
|
* fleet secret); fall back to the same GitHub token already used for the
|
|
@@ -999,11 +1337,29 @@ function resolveGithubToken() {
|
|
|
999
1337
|
* in the common fleet setup. Empty string when neither is available — the
|
|
1000
1338
|
* bootstrap then skips login and the private pulls fall back to a source
|
|
1001
1339
|
* build (today's behavior, never worse). Threaded into gcp-bootstrap.sh via
|
|
1002
|
-
* the env channel in ensureBootstrapped; never passed as an argv.
|
|
1340
|
+
* the env channel in ensureBootstrapped; never passed as an argv.
|
|
1341
|
+
*
|
|
1342
|
+
* This token is NOT sufficient for the post-compile PUBLISH — a `docker push`
|
|
1343
|
+
* needs write:packages. Reusing it for the push is exactly the 403 that kept
|
|
1344
|
+
* publish-on-compile from ever priming the fast path. Use resolveGhcrPushToken
|
|
1345
|
+
* for that. */
|
|
1003
1346
|
function resolveGhcrToken() {
|
|
1004
1347
|
return process.env.CODEYAM_GHCR_TOKEN || resolveGithubToken();
|
|
1005
1348
|
}
|
|
1006
1349
|
|
|
1350
|
+
/** A WRITE-scoped (`write:packages`) token used ONLY for the post-compile
|
|
1351
|
+
* publish — `publish_compiled_binary`'s `docker push` of the just-built
|
|
1352
|
+
* binary. Deliberately has NO fallback to the read/clone token: a `docker
|
|
1353
|
+
* push` with a read-only token 403s, so silently reusing the read token would
|
|
1354
|
+
* reintroduce the exact failure that kept the fast path from priming. Returns
|
|
1355
|
+
* the empty string when CODEYAM_GHCR_PUSH_TOKEN is unset — the bootstrap then
|
|
1356
|
+
* SKIPS the publish loudly (no doomed push, no 403 spam) and the next VM
|
|
1357
|
+
* simply recompiles that commit. Threaded into gcp-bootstrap.sh via the env
|
|
1358
|
+
* channel in ensureBootstrapped; never passed as an argv. */
|
|
1359
|
+
function resolveGhcrPushToken() {
|
|
1360
|
+
return process.env.CODEYAM_GHCR_PUSH_TOKEN || "";
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1007
1363
|
/** Write the laptop's GitHub token into ~/.codeyam-cloud-git-credentials on
|
|
1008
1364
|
* the VM so the cloud compose can mount it into /root/.git-credentials —
|
|
1009
1365
|
* without this, claude / codex / gemini inside the container can clone the
|
|
@@ -1121,37 +1477,76 @@ function buildEditorLocalSeedWriteCommand({ repoName, remotePath, b64 }) {
|
|
|
1121
1477
|
async function seedPreviewOrigin(cfg, appPort = VITE_PORT) {
|
|
1122
1478
|
const repoName = resolveProject(cfg).name;
|
|
1123
1479
|
const remotePath = `~/projects/${repoName}/.codeyam/editor.local.json`;
|
|
1124
|
-
const
|
|
1125
|
-
cfg.instanceName,
|
|
1126
|
-
readCloudflaredUrls(),
|
|
1127
|
-
appPort,
|
|
1128
|
-
);
|
|
1480
|
+
const stackPath = `~/projects/${repoName}/.codeyam/stack.json`;
|
|
1129
1481
|
|
|
1482
|
+
// One round-trip reads both the current editor.local.json (for the
|
|
1483
|
+
// skip-on-already-set guard) and stack.json (for the sameOriginSafe flag that
|
|
1484
|
+
// decides same-origin subpath vs dedicated origin). Mirrors readVmAppPort's
|
|
1485
|
+
// separator-delimited cat.
|
|
1130
1486
|
const read = runGcloud(
|
|
1131
1487
|
buildGcloudArgs("ssh-probe", cfg, {
|
|
1132
|
-
command:
|
|
1488
|
+
command:
|
|
1489
|
+
`cat ${remotePath} 2>/dev/null || echo '{}'; ` +
|
|
1490
|
+
`echo '<<<STACK>>>'; ` +
|
|
1491
|
+
`cat ${stackPath} 2>/dev/null || echo '{}'`,
|
|
1133
1492
|
}),
|
|
1134
1493
|
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
1135
1494
|
);
|
|
1136
|
-
|
|
1137
|
-
if (read.status === 0) {
|
|
1495
|
+
const parseObj = (s) => {
|
|
1138
1496
|
try {
|
|
1139
|
-
const
|
|
1140
|
-
|
|
1141
|
-
current = parsed;
|
|
1142
|
-
}
|
|
1497
|
+
const v = JSON.parse(s);
|
|
1498
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
1143
1499
|
} catch {
|
|
1144
1500
|
// Unparseable remote content is treated as `{}` — overwriting a
|
|
1145
1501
|
// corrupt local override file with a known-good one is the safer
|
|
1146
1502
|
// move than refusing to seed at all.
|
|
1503
|
+
return {};
|
|
1147
1504
|
}
|
|
1505
|
+
};
|
|
1506
|
+
let current = {};
|
|
1507
|
+
let sameOriginSafe = true;
|
|
1508
|
+
if (read.status === 0 && typeof read.stdout === "string") {
|
|
1509
|
+
const [localJson = "", stackJson = ""] = read.stdout.split("<<<STACK>>>");
|
|
1510
|
+
current = parseObj(localJson);
|
|
1511
|
+
sameOriginSafe = previewSameOriginSafeFromStack(parseObj(stackJson));
|
|
1148
1512
|
}
|
|
1149
|
-
|
|
1513
|
+
|
|
1514
|
+
const seed = resolveSeedPreviewOrigin(
|
|
1515
|
+
cfg.instanceName,
|
|
1516
|
+
readCloudflaredUrls(),
|
|
1517
|
+
appPort,
|
|
1518
|
+
process.env,
|
|
1519
|
+
sameOriginSafe,
|
|
1520
|
+
);
|
|
1521
|
+
|
|
1522
|
+
// Skip-on-already-set guard, EXCEPT for self-heal. A non-empty previewOrigin
|
|
1523
|
+
// is normally a user pin or a good prior seed and is left untouched. The one
|
|
1524
|
+
// exception is a ROTTED managed cloudflared quick-tunnel URL on a
|
|
1525
|
+
// same-origin-safe VM (the VM-1/2/6 leftover from before the improve38
|
|
1526
|
+
// same-origin migration): that frozen URL points at a dead rotated tunnel
|
|
1527
|
+
// (NXDOMAIN / blank preview), so we heal it back to the same-origin path
|
|
1528
|
+
// instead of preserving it. Because every dashboard action (Reset / Update /
|
|
1529
|
+
// Add / bootstrap-recovery) re-runs this seed over cloud:up, the heal reaches
|
|
1530
|
+
// every touch-point without a separate per-VM hand-fix.
|
|
1531
|
+
const originAlreadySet =
|
|
1532
|
+
typeof current.previewOrigin === "string" && current.previewOrigin.length > 0;
|
|
1533
|
+
const healing =
|
|
1534
|
+
originAlreadySet && isRottedManagedPreviewOrigin(current, sameOriginSafe);
|
|
1535
|
+
if (originAlreadySet && !healing) {
|
|
1150
1536
|
return;
|
|
1151
1537
|
}
|
|
1152
1538
|
|
|
1153
|
-
|
|
1154
|
-
|
|
1539
|
+
// On heal, also DROP the legacy `_managedPreviewOrigin` marker (deepMergeJson
|
|
1540
|
+
// deletes any key whose overlay value is null) — the same-origin path is a
|
|
1541
|
+
// constant the retired monitor push has nothing to manage, exactly matching
|
|
1542
|
+
// the manual `config-override --unset _managedPreviewOrigin` fix.
|
|
1543
|
+
const overlay = healing ? { ...seed, _managedPreviewOrigin: null } : seed;
|
|
1544
|
+
console.error(
|
|
1545
|
+
healing
|
|
1546
|
+
? `Healing rotted managed previewOrigin (${current.previewOrigin}) → ${seed.previewOrigin} in ${remotePath}...`
|
|
1547
|
+
: `Seeding previewOrigin=${seed.previewOrigin} into ${remotePath}...`,
|
|
1548
|
+
);
|
|
1549
|
+
const merged = deepMergeJson(current, overlay);
|
|
1155
1550
|
const mergedJson = JSON.stringify(merged, null, 2);
|
|
1156
1551
|
const b64 = Buffer.from(mergedJson, "utf8").toString("base64");
|
|
1157
1552
|
const write = runGcloud(
|
|
@@ -1485,12 +1880,15 @@ module.exports = {
|
|
|
1485
1880
|
rosterProvisionedVm,
|
|
1486
1881
|
resolveConfiguredAppPort,
|
|
1487
1882
|
resolveSeedPreviewOrigin,
|
|
1883
|
+
previewSameOriginSafeFromStack,
|
|
1884
|
+
isRottedManagedPreviewOrigin,
|
|
1488
1885
|
resolveEditorConfig,
|
|
1489
1886
|
validateEditorVariant,
|
|
1490
1887
|
assertVariantProvisionable,
|
|
1491
1888
|
editorImageBootstrapArgs,
|
|
1492
1889
|
reconcileProjectExclusivity,
|
|
1493
1890
|
resolveGhcrToken,
|
|
1891
|
+
resolveGhcrPushToken,
|
|
1494
1892
|
resolveProject,
|
|
1495
1893
|
clientProjectBootstrapArgs,
|
|
1496
1894
|
cloudConfigPath,
|
|
@@ -1498,8 +1896,18 @@ module.exports = {
|
|
|
1498
1896
|
saveCloudConfig,
|
|
1499
1897
|
buildEditorLocalEnvPatch,
|
|
1500
1898
|
buildEditorLocalSeedWriteCommand,
|
|
1899
|
+
seedPreviewOrigin,
|
|
1501
1900
|
buildGcloudArgs,
|
|
1502
1901
|
sshScriptCommand,
|
|
1902
|
+
bootstrapRunnerScript,
|
|
1903
|
+
bootstrapLaunchCommand,
|
|
1904
|
+
bootstrapFollowCommand,
|
|
1905
|
+
bootstrapStatusCommand,
|
|
1906
|
+
parseBootstrapStatus,
|
|
1907
|
+
decideBootstrapOutcome,
|
|
1908
|
+
projectBootstrapDoneMarker,
|
|
1909
|
+
projectBootstrapDoneProbeCommand,
|
|
1910
|
+
SSH_KEEPALIVE_FLAGS,
|
|
1503
1911
|
resolveSetupForwardEnv,
|
|
1504
1912
|
FORWARD_ENV_KEYS_ENV,
|
|
1505
1913
|
parseEditorComposeState,
|